Xây dựng SaaS với Laravel (Phần 8): Deploy Production & Scaling

· 13 min read

Đây là phần cuối cùng trong series "Xây dựng SaaS với Laravel". Ở các phần trước, chúng ta đã xây dựng toàn bộ features. Giờ là lúc đưa sản phẩm lên 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 cho 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 cho 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 với 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

Cho subdomain routing, cần wildcard SSL certificate:

Với Certbot (Let's Encrypt)

# Cài đặt certbot với DNS plugin (ví dụ: Cloudflare)
sudo apt install certbot python3-certbot-dns-cloudflare

# Tạo 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

# Tạo 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, trước 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 cho 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,
    ],
],

Sử dụng:

// 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

// Sử dụng 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, // Tránh read-after-write inconsistency
    'driver' => 'mysql',
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
],

Queue Scaling

# Tách queue theo 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

Trước khi launch, kiểm tra từng 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

Kiến trúc tổng quan - Toàn Series

┌─────────────────────────────────────────────────────────────┐
│                        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            │
└─────────────────────────────────────────────────────────────┘

Tổng kết Series

Qua 8 phần, chúng ta đã xây dựng một ứng dụng SaaS hoàn chỉnh:

Phần Nội dung Key Concepts
1 Khởi tạo & kiến trúc Domain structure, migrations, models
2 Multi-tenancy Global scopes, data isolation, 4 layers bảo vệ
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 phải defense-in-depth — không bao giờ chỉ dựa vào 1 lớp bảo vệ
  2. Billing là core — đầu tư thời gian cho webhook handling và edge cases
  3. Usage limits tạo incentive upgrade — thiết kế giới hạn thông minh
  4. Monitor everything — MRR, churn, conversion là metrics sống còn
  5. API là premium feature — tạo giá trị cho higher-tier plans
  6. Deploy automation — manual deployment sẽ giết bạn khi scale

Next Steps

Nếu muốn phát triển thêm:

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

Cảm ơn bạn đã theo dõi series. Happy building! 🚀

Bình luận