Laravel Forge Deployment Automation: From Development to Production

Laravel Forge revolutionizes how we deploy and manage Laravel applications. By automating server provisioning, deployments, and maintenance tasks, Forge allows developers to focus on building features rather than wrestling with server configurations. Let's explore how to leverage Forge for professional deployment workflows.

Understanding Laravel Forge Architecture

Server Provisioning and Setup

Forge simplifies server management across multiple cloud providers:

# Forge automatically sets up servers with:
# - PHP 8.2+ with required extensions
# - Nginx web server
# - MySQL/PostgreSQL databases
# - Redis for caching and queues
# - Node.js for asset compilation
# - SSL certificates via Let's Encrypt
# - Firewall configuration
# - Process monitoring with Supervisor

Site Configuration Best Practices

When creating sites in Forge, optimize for production:

# Forge generates optimized Nginx configuration
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com;
    root /home/forge/yourdomain.com/public;

    # SSL Configuration (managed by Forge)
    ssl_certificate /etc/nginx/ssl/yourdomain.com/server.crt;
    ssl_certificate_key /etc/nginx/ssl/yourdomain.com/server.key;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;

    # Laravel-specific configuration
    index index.php;
    charset utf-8;

    # Handle Laravel routes
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP handling
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
        
        # Production optimizations
        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;
    }

    # Asset optimization
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

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

Advanced Deployment Strategies

Zero-Downtime Deployment Scripts

Create sophisticated deployment scripts for production environments:

#!/bin/bash
# Custom Forge deployment script for zero-downtime deployments

set -e

# Configuration
FORGE_SITE_PATH="/home/forge/yourdomain.com"
BACKUP_PATH="/home/forge/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

echo "๐Ÿš€ Starting deployment at $(date)"

# Create backup directory if it doesn't exist
mkdir -p $BACKUP_PATH

cd $FORGE_SITE_PATH

# 1. Backup current release
echo "๐Ÿ“ฆ Creating backup..."
if [ -d "current" ]; then
    cp -r current $BACKUP_PATH/backup_$TIMESTAMP
fi

# 2. Clone fresh copy to new directory
echo "โฌ‡๏ธ  Pulling latest code..."
RELEASE_DIR="releases/release_$TIMESTAMP"
mkdir -p releases
git clone --depth 1 --branch production $FORGE_SITE_PATH $RELEASE_DIR
cd $RELEASE_DIR

# 3. Install PHP dependencies
echo "๐Ÿ“š Installing Composer dependencies..."
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist

# 4. Install and build Node.js assets
echo "๐ŸŽจ Building assets..."
npm ci --production=false
npm run production

# 5. Copy environment file
echo "โš™๏ธ  Configuring environment..."
cp $FORGE_SITE_PATH/.env .env

# 6. Generate application key if needed
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
    php artisan key:generate --force
fi

# 7. Run database migrations
echo "๐Ÿ—„๏ธ  Running migrations..."
php artisan migrate --force

# 8. Cache optimization
echo "๐Ÿ”ง Optimizing caches..."
php artisan config:cache
php artisan route:cache
php artisan view:cache

# 9. Queue optimization
echo "โšก Optimizing queues..."
php artisan queue:restart

# 10. Warm up application
echo "๐Ÿ”ฅ Warming up application..."
curl -s -o /dev/null -w "%{http_code}" "https://yourdomain.com" || true

# 11. Create symbolic link (atomic swap)
echo "๐Ÿ”„ Switching to new release..."
cd $FORGE_SITE_PATH
ln -nfs $RELEASE_DIR current-temp
mv current-temp current

# 12. Reload PHP-FPM
echo "๐Ÿ”„ Reloading PHP-FPM..."
sudo service php8.2-fpm reload

# 13. Clean up old releases (keep last 5)
echo "๐Ÿงน Cleaning old releases..."
cd releases
ls -t | tail -n +6 | xargs rm -rf

echo "โœ… Deployment completed successfully at $(date)"

# 14. Health check
echo "๐Ÿฉบ Running health check..."
if curl -f -s "https://yourdomain.com/up" > /dev/null; then
    echo "โœ… Health check passed"
else
    echo "โŒ Health check failed - consider rollback"
fi

# 15. Send deployment notification
echo "๐Ÿ“ข Sending deployment notification..."
curl -X POST -H 'Content-type: application/json' \
    --data "{\"text\":\"๐Ÿš€ Deployment completed for yourdomain.com at $(date)\"}" \
    $SLACK_WEBHOOK_URL

Environment-Specific Configurations

Set up different deployment strategies per environment:

# Staging deployment script
#!/bin/bash
set -e

cd $FORGE_SITE_PATH

# Staging allows more verbose output and testing
echo "๐Ÿงช Staging deployment started"

# Pull code
git pull origin staging

# Install all dependencies (including dev)
composer install --optimize-autoloader

# Install Node dependencies with dev tools
npm ci

# Build assets for staging (with source maps)
npm run dev

# Run migrations with seeding for testing data
php artisan migrate:fresh --seed --force

# Clear all caches for development
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear

# Run tests to ensure deployment is working
echo "๐Ÿงช Running tests..."
php artisan test --coverage --parallel

# Queue setup for staging
php artisan queue:restart
php artisan horizon:terminate

echo "โœ… Staging deployment completed"

Database Migration Strategies

Implement safe migration practices:

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

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

class SafeMigrate extends Command
{
    protected $signature = 'migrate:safe {--backup} {--check}';
    protected $description = 'Run migrations with safety checks and backup option';

    public function handle()
    {
        if ($this->option('check')) {
            return $this->checkMigrations();
        }

        if ($this->option('backup')) {
            $this->createBackup();
        }

        return $this->runMigrations();
    }

    private function checkMigrations()
    {
        $this->info('๐Ÿ” Checking pending migrations...');
        
        $pending = $this->getPendingMigrations();
        
        if (empty($pending)) {
            $this->info('โœ… No pending migrations');
            return 0;
        }

        $this->warn('โš ๏ธ  Found ' . count($pending) . ' pending migrations:');
        foreach ($pending as $migration) {
            $this->line("  - {$migration}");
        }

        // Check for potentially destructive migrations
        $destructive = $this->checkForDestructiveOperations($pending);
        
        if (!empty($destructive)) {
            $this->error('๐Ÿšจ Found potentially destructive migrations:');
            foreach ($destructive as $migration => $operations) {
                $this->error("  {$migration}: " . implode(', ', $operations));
            }
            return 1;
        }

        return 0;
    }

    private function createBackup()
    {
        $this->info('๐Ÿ’พ Creating database backup...');
        
        $database = config('database.connections.mysql.database');
        $timestamp = date('Y-m-d_H-i-s');
        $backupFile = storage_path("backups/db_backup_{$timestamp}.sql");
        
        // Ensure backup directory exists
        if (!is_dir(dirname($backupFile))) {
            mkdir(dirname($backupFile), 0755, true);
        }

        // Create mysqldump backup
        $command = sprintf(
            'mysqldump -h%s -u%s -p%s %s > %s',
            config('database.connections.mysql.host'),
            config('database.connections.mysql.username'),
            config('database.connections.mysql.password'),
            $database,
            $backupFile
        );

        exec($command, $output, $returnCode);

        if ($returnCode === 0) {
            $this->info("โœ… Backup created: {$backupFile}");
        } else {
            $this->error('โŒ Backup failed');
            return 1;
        }
    }

    private function runMigrations()
    {
        $this->info('๐Ÿ—„๏ธ  Running migrations...');

        try {
            $this->call('migrate', ['--force' => true]);
            $this->info('โœ… Migrations completed successfully');
            return 0;
        } catch (\Exception $e) {
            $this->error('โŒ Migration failed: ' . $e->getMessage());
            return 1;
        }
    }

    private function getPendingMigrations(): array
    {
        $migrationFiles = glob(database_path('migrations/*.php'));
        $ranMigrations = DB::table('migrations')->pluck('migration')->toArray();
        
        $pending = [];
        
        foreach ($migrationFiles as $file) {
            $migrationName = basename($file, '.php');
            if (!in_array($migrationName, $ranMigrations)) {
                $pending[] = $migrationName;
            }
        }
        
        return $pending;
    }

    private function checkForDestructiveOperations(array $migrations): array
    {
        $destructive = [];
        
        foreach ($migrations as $migration) {
            $filePath = database_path("migrations/{$migration}.php");
            if (file_exists($filePath)) {
                $content = file_get_contents($filePath);
                $operations = [];
                
                if (strpos($content, '->drop') !== false) {
                    $operations[] = 'DROP operations detected';
                }
                
                if (strpos($content, 'Schema::dropIfExists') !== false) {
                    $operations[] = 'DROP TABLE operations detected';
                }
                
                if (strpos($content, '->dropColumn') !== false) {
                    $operations[] = 'DROP COLUMN operations detected';
                }
                
                if (!empty($operations)) {
                    $destructive[$migration] = $operations;
                }
            }
        }
        
        return $destructive;
    }
}

CI/CD Integration

GitHub Actions Integration

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

on:
  push:
    branches: [ production ]

jobs:
  tests:
    name: Run Tests
    runs-on: ubuntu-latest
    
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
    - uses: actions/checkout@v2
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.2'
        extensions: mbstring, dom, fileinfo, mysql
        coverage: xdebug

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

    - name: Copy environment file
      run: cp .env.example .env

    - name: Generate application key
      run: php artisan key:generate

    - name: Run migrations
      run: php artisan migrate --force

    - name: Install Node dependencies
      run: npm ci

    - name: Build assets
      run: npm run production

    - name: Run tests
      run: php artisan test --coverage-clover coverage.xml

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v1

  security:
    name: Security Scan
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.2'

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

    - name: Run security scan
      run: composer audit

  deploy:
    name: Deploy to Forge
    runs-on: ubuntu-latest
    needs: [tests, security]
    if: github.ref == 'refs/heads/production'
    
    steps:
    - name: Deploy to server
      uses: jbrooksuk/laravel-forge-action@v1.0.0
      with:
        trigger_url: ${{ secrets.FORGE_TRIGGER_URL }}

    - name: Verify deployment
      run: |
        sleep 30
        response=$(curl -s -o /dev/null -w "%{http_code}" https://yourdomain.com/up)
        if [ $response -eq 200 ]; then
          echo "โœ… Deployment successful"
        else
          echo "โŒ Deployment failed with status: $response"
          exit 1
        fi

    - name: Notify Slack
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        channel: '#deployments'
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}
      if: always()

Rollback Strategies

# Rollback script for Forge
#!/bin/bash
set -e

FORGE_SITE_PATH="/home/forge/yourdomain.com"
BACKUP_PATH="/home/forge/backups"

echo "๐Ÿ”„ Starting rollback process..."

cd $FORGE_SITE_PATH

# Find the latest backup
LATEST_BACKUP=$(ls -t $BACKUP_PATH/backup_* | head -n 1)

if [ -z "$LATEST_BACKUP" ]; then
    echo "โŒ No backup found for rollback"
    exit 1
fi

echo "๐Ÿ“ฆ Rolling back to: $LATEST_BACKUP"

# Stop services that might be using the application
echo "โน๏ธ  Stopping services..."
sudo supervisorctl stop all

# Backup current state (in case rollback fails)
ROLLBACK_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
cp -r current $BACKUP_PATH/pre_rollback_$ROLLBACK_TIMESTAMP

# Replace current with backup
rm -rf current
cp -r $LATEST_BACKUP current

# Restore database from backup if available
DB_BACKUP="${LATEST_BACKUP}_database.sql"
if [ -f "$DB_BACKUP" ]; then
    echo "๐Ÿ—„๏ธ  Restoring database..."
    mysql -u$DB_USERNAME -p$DB_PASSWORD $DB_DATABASE < $DB_BACKUP
fi

cd current

# Reinstall dependencies (in case of version mismatches)
echo "๐Ÿ“š Reinstalling dependencies..."
composer install --no-dev --optimize-autoloader

# Clear caches
echo "๐Ÿงน Clearing caches..."
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear

# Restart services
echo "๐Ÿ”„ Restarting services..."
php artisan queue:restart
sudo supervisorctl start all
sudo service php8.2-fpm reload

# Health check
echo "๐Ÿฉบ Running health check..."
if curl -f -s "https://yourdomain.com/up" > /dev/null; then
    echo "โœ… Rollback completed successfully"
else
    echo "โŒ Rollback health check failed"
    exit 1
fi

echo "โœ… Rollback completed at $(date)"

Performance Optimization

Caching Strategies

# Production optimization script
#!/bin/bash

echo "๐Ÿš€ Optimizing for production..."

# Optimize Composer autoloader
composer install --no-dev --optimize-autoloader

# Cache Laravel configurations
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# Optimize OPcache settings
sudo tee /etc/php/8.2/fpm/conf.d/99-opcache.ini << EOF
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.fast_shutdown=1
EOF

# Restart PHP-FPM to apply changes
sudo service php8.2-fpm restart

echo "โœ… Production optimization complete"

Database Optimization

// Schedule database maintenance
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // Optimize database tables weekly
    $schedule->call(function () {
        DB::statement('OPTIMIZE TABLE users, posts, comments');
    })->weekly()->sundays()->at('02:00');

    // Clean up old logs and temporary data
    $schedule->call(function () {
        DB::table('telescope_entries')
            ->where('created_at', '<', now()->subDays(7))
            ->delete();
    })->daily()->at('03:00');

    // Backup database before maintenance
    $schedule->command('backup:database')
        ->weekly()
        ->sundays()
        ->at('01:30');
}

Monitoring and Alerting

Server Monitoring Setup

# Install server monitoring (runs automatically via Forge)
# Monitor disk space
df -h | awk '$5 > 80 {print "Disk space warning: " $1 " is " $5 " full"}'

# Monitor memory usage
free | awk 'NR==2{printf "Memory Usage: %.2f%\n", $3*100/$2}'

# Monitor PHP-FPM processes
ps aux | grep php-fpm | wc -l

# Monitor queue workers
sudo supervisorctl status | grep laravel-worker

Application Health Monitoring

// app/Http/Controllers/HealthController.php
class HealthController extends Controller
{
    public function check()
    {
        $checks = [
            'database' => $this->checkDatabase(),
            'redis' => $this->checkRedis(),
            'queue' => $this->checkQueue(),
            'storage' => $this->checkStorage(),
        ];

        $healthy = collect($checks)->every(fn($check) => $check['status'] === 'ok');

        return response()->json([
            'status' => $healthy ? 'ok' : 'error',
            'checks' => $checks,
            'timestamp' => now()->toISOString(),
        ], $healthy ? 200 : 503);
    }

    private function checkDatabase(): array
    {
        try {
            DB::connection()->getPdo();
            return ['status' => 'ok', 'message' => 'Database connected'];
        } catch (Exception $e) {
            return ['status' => 'error', 'message' => $e->getMessage()];
        }
    }

    private function checkRedis(): array
    {
        try {
            Redis::ping();
            return ['status' => 'ok', 'message' => 'Redis connected'];
        } catch (Exception $e) {
            return ['status' => 'error', 'message' => $e->getMessage()];
        }
    }

    private function checkQueue(): array
    {
        try {
            $size = Queue::size();
            return [
                'status' => $size < 1000 ? 'ok' : 'warning',
                'message' => "Queue size: {$size}",
                'queue_size' => $size
            ];
        } catch (Exception $e) {
            return ['status' => 'error', 'message' => $e->getMessage()];
        }
    }

    private function checkStorage(): array
    {
        try {
            $diskFree = disk_free_space(storage_path());
            $diskTotal = disk_total_space(storage_path());
            $percentUsed = (($diskTotal - $diskFree) / $diskTotal) * 100;

            return [
                'status' => $percentUsed < 90 ? 'ok' : 'warning',
                'message' => "Disk usage: " . round($percentUsed, 2) . "%",
                'disk_usage_percent' => round($percentUsed, 2)
            ];
        } catch (Exception $e) {
            return ['status' => 'error', 'message' => $e->getMessage()];
        }
    }
}

Conclusion

Laravel Forge transforms deployment from a complex, error-prone process into a streamlined, automated workflow. By leveraging Forge's capabilities along with proper deployment scripts, CI/CD integration, and monitoring, you can achieve professional-grade deployment pipelines.

Key benefits of mastering Forge:

  • Automated server management reduces operational overhead
  • Zero-downtime deployments ensure continuous availability
  • Integrated monitoring catches issues before they affect users
  • Rollback capabilities provide safety nets for failed deployments
  • CI/CD integration enables modern development workflows

Start with basic Forge functionality and gradually implement advanced features like custom deployment scripts, automated testing, and comprehensive monitoring. The investment in proper deployment automation pays dividends in reduced stress, faster iterations, and more reliable applications.