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
- Multi-tenancy phải defense-in-depth — không bao giờ chỉ dựa vào 1 lớp bảo vệ
- Billing là core — đầu tư thời gian cho webhook handling và edge cases
- Usage limits tạo incentive upgrade — thiết kế giới hạn thông minh
- Monitor everything — MRR, churn, conversion là metrics sống còn
- API là premium feature — tạo giá trị cho higher-tier plans
- 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! 🚀