Laravel on VPS: PHP-FPM Tuning, Queue Workers, and Zero-Downtime Deploys

Published 20 min read
laravelphpvps hostingphp-fpmredisdeployment

A production Laravel application on a 4 vCPU VPS handles 800-1,200 requests per second with proper PHP-FPM tuning, OPcache configuration, and Redis queue management. This guide covers every configuration file needed to run Laravel in production, from nginx virtual hosts to Envoy zero-downtime deployment scripts.

All configurations are tested on Ubuntu 22.04 with PHP 8.3, Laravel 11, and Redis 7. For VPS provisioning, check available plans on the Pricing page.

PHP-FPM Process Manager Configuration

PHP-FPM is the process manager that handles incoming PHP requests. The default configuration is designed for shared hosting with minimal resources. A VPS needs a tuned configuration that matches your available RAM and expected traffic patterns.

There are three process management modes:

  • static: Fixed number of worker processes. Best for predictable traffic with consistent RAM availability.
  • dynamic: Workers scale between a minimum and maximum. Best for variable traffic patterns.
  • ondemand: Workers spawn only when requests arrive and die after idle timeout. Best for low-traffic sites sharing resources.

For a production Laravel application on a 4 vCPU, 8GB RAM VPS:

; /etc/php/8.3/fpm/pool.d/laravel.conf

[laravel]

user = www-data

group = www-data

listen = /run/php/php8.3-fpm-laravel.sock

listen.owner = www-data

listen.group = www-data

listen.mode = 0660

; Process management

pm = dynamic

pm.max_children = 50

pm.start_servers = 10

pm.min_spare_servers = 5

pm.max_spare_servers = 20

pm.max_requests = 1000

pm.process_idle_timeout = 10s

; Status page for monitoring

pm.status_path = /fpm-status

ping.path = /fpm-ping

; Logging

access.log = /var/log/php-fpm/laravel-access.log

slowlog = /var/log/php-fpm/laravel-slow.log

request_slowlog_timeout = 5s

; Environment variables

env[APP_ENV] = production

env[APP_DEBUG] = false

; PHP settings overrides

php_admin_value[memory_limit] = 256M

php_admin_value[max_execution_time] = 60

php_admin_value[upload_max_filesize] = 50M

php_admin_value[post_max_size] = 50M

The `pm.max_children = 50` setting is calculated from available RAM. Each PHP-FPM worker uses approximately 40-80MB of memory for a Laravel application. With 8GB RAM, after accounting for the OS (1GB), PostgreSQL (2GB), Redis (512MB), and nginx (256MB), roughly 4GB is available for PHP workers. At 80MB per worker, that is 50 workers maximum.

The `pm.max_requests = 1000` setting recycles workers after 1,000 requests, preventing memory leaks from accumulating. This is essential for long-running Laravel applications.

OPcache Configuration

OPcache stores compiled PHP bytecode in shared memory, eliminating the need to parse and compile PHP files on every request. For Laravel, this reduces response time by 50-70% because the framework bootstraps thousands of files per request.

; /etc/php/8.3/fpm/conf.d/10-opcache.ini

opcache.enable=1

opcache.memory_consumption=256

opcache.interned_strings_buffer=32

opcache.max_accelerated_files=20000

opcache.validate_timestamps=0

opcache.revalidate_freq=0

opcache.save_comments=1

opcache.enable_file_override=1

opcache.jit=1255

opcache.jit_buffer_size=128M

Critical setting: `opcache.validate_timestamps=0` disables file modification checks in production. This means OPcache never checks if PHP files have changed, giving maximum performance. The tradeoff is that you must restart PHP-FPM after every deployment to load new code. This is handled automatically in the Envoy deployment script below.

The JIT compiler (`opcache.jit=1255`) provides an additional 10-30% performance improvement for CPU-intensive operations like JSON serialization, collection manipulation, and string processing. The `1255` setting enables optimized JIT tracing, which is the best mode for web applications.

Redis Configuration for Laravel

Laravel uses Redis for three distinct purposes: cache store, session driver, and queue backend. Each should use a separate Redis database to allow independent flushing and monitoring.

// config/database.php - Redis connections

'redis' => [

'client' => env('REDIS_CLIENT', 'phpredis'),

'default' => [

'host' => env('REDIS_HOST', '127.0.0.1'),

'password' => env('REDIS_PASSWORD', null),

'port' => env('REDIS_PORT', 6379),

'database' => 0, // Application cache

'prefix' => 'cache:',

],

'session' => [

'host' => env('REDIS_HOST', '127.0.0.1'),

'password' => env('REDIS_PASSWORD', null),

'port' => env('REDIS_PORT', 6379),

'database' => 1, // Session storage

'prefix' => 'session:',

],

'queue' => [

'host' => env('REDIS_HOST', '127.0.0.1'),

'password' => env('REDIS_PASSWORD', null),

'port' => env('REDIS_PORT', 6379),

'database' => 2, // Queue jobs

'prefix' => 'queue:',

],

],

Using `phpredis` extension instead of `predis` (PHP library) gives 2-3x better performance on high-throughput workloads because it is a compiled C extension. Install it with `pecl install redis` and add `extension=redis.so` to your PHP configuration.

Laravel Horizon for Queue Management

Horizon provides a dashboard and configuration layer for Laravel queues. It monitors job throughput, failure rates, and queue wait times. Configure it for production workloads:

// config/horizon.php

'environments' => [

'production' => [

'supervisor-default' => [

'connection' => 'redis',

'queue' => ['default', 'emails', 'notifications'],

'balance' => 'auto',

'minProcesses' => 3,

'maxProcesses' => 10,

'balanceMaxShift' => 1,

'balanceCooldown' => 3,

'tries' => 3,

'timeout' => 300,

'maxTime' => 3600,

'memory' => 128,

'nice' => 0,

],

'supervisor-heavy' => [

'connection' => 'redis',

'queue' => ['reports', 'exports', 'imports'],

'balance' => 'auto',

'minProcesses' => 1,

'maxProcesses' => 5,

'tries' => 1,

'timeout' => 1800,

'maxTime' => 3600,

'memory' => 512,

],

],

],

The `balance => 'auto'` setting lets Horizon automatically distribute workers across queues based on workload. During peak email sending periods, it shifts workers to the email queue. During report generation, workers move to the heavy queue. This eliminates manual worker allocation.

Nginx Virtual Host Configuration

server {

listen 80;

server_name app.example.com;

return 301 https://$host$request_uri;

}

server {

listen 443 ssl http2;

server_name app.example.com;

root /var/www/app/current/public;

index index.php;

ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;

add_header X-Frame-Options "SAMEORIGIN" always;

add_header X-Content-Type-Options "nosniff" always;

add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Asset caching

location /build/ {

alias /var/www/app/current/public/build/;

expires 365d;

add_header Cache-Control "public, immutable";

access_log off;

}

location /storage/ {

alias /var/www/app/shared/storage/app/public/;

expires 30d;

add_header Cache-Control "public";

}

# PHP processing via FPM socket

location ~ \.php$ {

fastcgi_pass unix:/run/php/php8.3-fpm-laravel.sock;

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;

include fastcgi_params;

fastcgi_buffers 16 16k;

fastcgi_buffer_size 32k;

fastcgi_read_timeout 300;

}

location / {

try_files $uri $uri/ /index.php?$query_string;

}

# Block dotfiles except .well-known

location ~ /\.(?!well-known) {

deny all;

}

}

The `root` points to `/var/www/app/current/public` where `current` is a symlink managed by the Envoy deployment script. This enables zero-downtime deployments by switching the symlink to a new release directory.

Envoy Zero-Downtime Deployment Script

Laravel Envoy provides a simple syntax for defining deployment tasks. This script implements zero-downtime deployment with rollback capability:

<h1>Envoy.blade.php</h1>

@servers(['production' => 'deploy@your-server-ip'])

@setup

$repository = '[email protected]:your-org/your-app.git';

$releases_dir = '/var/www/app/releases';

$shared_dir = '/var/www/app/shared';

$current_dir = '/var/www/app/current';

$release = date('YmdHis');

$new_release_dir = $releases_dir . '/' . $release;

@endsetup

@story('deploy')

clone_repository

install_dependencies

build_assets

update_symlinks

optimize_application

restart_services

cleanup_old_releases

@endstory

@task('clone_repository')

echo "Cloning repository..."

git clone --depth 1 {{ $repository }} {{ $new_release_dir }}

@endtask

@task('install_dependencies')

echo "Installing Composer dependencies..."

cd {{ $new_release_dir }}

composer install --no-dev --optimize-autoloader --no-interaction

@endtask

@task('build_assets')

echo "Building frontend assets..."

cd {{ $new_release_dir }}

npm ci && npm run build

@endtask

@task('update_symlinks')

echo "Updating symlinks..."

ln -nfs {{ $shared_dir }}/.env {{ $new_release_dir }}/.env

ln -nfs {{ $shared_dir }}/storage {{ $new_release_dir }}/storage

ln -nfs {{ $new_release_dir }} {{ $current_dir }}

@endtask

@task('optimize_application')

echo "Optimizing Laravel..."

cd {{ $current_dir }}

php artisan config:cache

php artisan route:cache

php artisan view:cache

php artisan event:cache

php artisan migrate --force

@endtask

@task('restart_services')

echo "Restarting services..."

sudo systemctl reload php8.3-fpm

sudo supervisorctl restart horizon

@endtask

@task('cleanup_old_releases')

echo "Cleaning old releases..."

cd {{ $releases_dir }}

ls -dt */

tail -n +6
xargs rm -rf

@endtask

Deploy with a single command: `envoy run deploy`. The script clones the new release, installs dependencies, builds assets, switches the symlink, and restarts PHP-FPM. The old release remains on disk for instant rollback. Only the last 5 releases are kept to conserve disk space.

For managed deployment pipelines that handle this automatically, explore the Services Overview page.

Supervisor Configuration for Queue Workers

; /etc/supervisor/conf.d/horizon.conf

[program:horizon]

process_name=%(program_name)s

command=php /var/www/app/current/artisan horizon

autostart=true

autorestart=true

user=www-data

redirect_stderr=true

stdout_logfile=/var/log/supervisor/horizon.log

stopwaitsecs=3600

stopsignal=SIGTERM

The `stopwaitsecs=3600` setting gives Horizon up to 1 hour to finish processing the current job before the supervisor forces a kill. This prevents data corruption in long-running jobs like report generation or CSV exports.

Laravel Performance Checklist

Run through this checklist before going to production:

ItemCommandImpact

Config cache`php artisan config:cache`30% faster boot
Route cache`php artisan route:cache`50% faster routing
View cache`php artisan view:cache`20% faster rendering
Event cache`php artisan event:cache`10% faster events
Composer autoload`composer dump-autoload -o`15% faster class loading
Debug off`APP_DEBUG=false`Prevents info leaks
Query logging off`DB_LOG_QUERIES=false`5-10% less memory
Telescope disabledRemove from production providers20% less overhead

Combined, these optimizations improve Laravel response time by 40-60% compared to a default development configuration.

Read the Documentation for additional PHP hosting optimization guides and deployment patterns.

Frequently Asked Questions

Q: How many PHP-FPM workers should I run?

A: Divide available RAM (after OS, database, Redis, nginx) by the average memory per worker (40-80MB for Laravel). A 4GB VPS typically runs 25-50 workers. Monitor with `pm.status_path` and adjust based on actual memory usage.

Q: Should I use nginx or Apache for Laravel?

A: Nginx. It handles 2-5x more concurrent connections with 50% less memory. Apache with mod_php is simpler to configure but cannot match nginx + PHP-FPM performance at scale. Every serious Laravel deployment uses nginx.

Q: How do I handle Laravel scheduled tasks on a VPS?

A: Add a single cron entry: `* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1`. Laravel's scheduler handles all task timing internally. Do not create individual cron entries for each task.

Q: What is the best session driver for production?

A: Redis. File sessions create I/O bottlenecks under load and cannot be shared across multiple servers. Database sessions add unnecessary query load. Redis sessions are in-memory, fast, and automatically expire. Configure with `SESSION_DRIVER=redis` in your `.env` file.

Q: How do I monitor PHP-FPM performance?

A: Enable the status page with `pm.status_path = /fpm-status` in your pool configuration. Monitor `active processes`, `idle processes`, `listen queue`, and `max children reached` metrics. If `max children reached` is greater than 0, increase `pm.max_children` or add more RAM.

Q: Can I run multiple Laravel applications on one VPS?

A: Yes. Create separate PHP-FPM pools (separate `.conf` files in `/etc/php/8.3/fpm/pool.d/`) with different socket paths, and configure separate nginx server blocks pointing to each pool. Each application gets isolated processes and memory limits. Contact us through the Contact page for multi-application hosting architecture advice.