How to Deploy Laravel on a VPS: PHP-FPM, Queue Workers, Horizon, and Zero-Downtime Deploys
Laravel is the most widely used PHP framework for modern web applications, and deploying it on a VPS gives you full control over your server configuration, PHP version, queue system, and deployment pipeline. This guide covers the complete production deployment stack: Nginx, PHP-FPM 8.2, MySQL/MariaDB, Redis for caching and queues, Laravel Horizon for queue monitoring, Supervisor for process management, and a zero-downtime deployment workflow using Laravel’s atomic deployment pattern.
Architecture Overview
A production Laravel deployment has these components:
- Nginx — reverse proxy and static file server with SSL termination
- PHP-FPM 8.2 — processes PHP requests with OPcache for compiled bytecode caching
- MariaDB — primary relational database for application data
- Redis — session storage, application cache, and queue backend
- Supervisor — manages queue worker processes, restarting them automatically if they crash
- Laravel Horizon — dashboard and configuration for Redis queue workers
Prerequisites
- Ubuntu 22.04 LTS VPS with at least 2 GB RAM
- A non-root sudo user with SSH key authentication configured
- Domain name pointed to your VPS IP
- UFW firewall active with ports 22, 80, and 443 open
Step 1: Install PHP 8.2 and Required Extensions
sudo apt update && sudo apt upgrade -y
sudo apt install software-properties-common -y
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install php8.2-fpm php8.2-cli php8.2-mysql php8.2-pgsql \
php8.2-redis php8.2-mbstring php8.2-xml php8.2-curl php8.2-zip \
php8.2-bcmath php8.2-gd php8.2-intl php8.2-imagick -y
Install Composer:
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version
Step 2: Configure PHP-FPM for Laravel
sudo nano /etc/php/8.2/fpm/pool.d/laravel.conf
[laravel]
user = www-data
group = www-data
listen = /var/run/php/laravel.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500
php_admin_value[error_log] = /var/log/php/laravel-error.log
php_admin_flag[log_errors] = on
Configure OPcache for optimal performance:
sudo nano /etc/php/8.2/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.huge_code_pages=0
Setting validate_timestamps=0 is safe for production deployments where you control when OPcache is reset (during deployment). It eliminates the filesystem stat call on every request, significantly improving throughput.
sudo mkdir -p /var/log/php && sudo chown www-data:www-data /var/log/php
sudo systemctl restart php8.2-fpm
Step 3: Install and Configure MariaDB
sudo apt install mariadb-server -y
sudo mysql_secure_installation
sudo mysql -u root -p
CREATE DATABASE laravel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'laravel'@'localhost' IDENTIFIED BY 'StrongDBPassword!';
GRANT ALL PRIVILEGES ON laravel_db.* TO 'laravel'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Step 4: Install Redis
sudo apt install redis-server -y
sudo systemctl enable redis-server
# Configure Redis for production
sudo nano /etc/redis/redis.conf
Key production settings:
maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no
Disabling persistence (save "", appendonly no) makes Redis faster for cache and queue use cases where data does not need to survive a Redis restart.
sudo systemctl restart redis-server
Step 5: Deploy Your Laravel Application
sudo mkdir -p /var/www/laravel
sudo chown www-data:www-data /var/www/laravel
cd /var/www/laravel
sudo -u www-data git clone https://github.com/yourorg/myapp.git current
cd current
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data cp .env.example .env
sudo -u www-data php artisan key:generate
Configure the production .env:
sudo -u www-data nano .env
APP_NAME="My Application"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_db
DB_USERNAME=laravel
DB_PASSWORD=StrongDBPassword!
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan storage:link
# Optimize for production
sudo -u www-data php artisan config:cache
sudo -u www-data php artisan route:cache
sudo -u www-data php artisan view:cache
sudo -u www-data php artisan event:cache
Step 6: Configure Nginx for Laravel
sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/laravel
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/laravel/current/public;
index index.php;
client_max_body_size 50M;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/laravel.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
}
sudo ln -s /etc/nginx/sites-available/laravel /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Step 7: Set Up Queue Workers with Supervisor
Laravel queue workers process jobs asynchronously — sending emails, processing images, triggering webhooks. Supervisor ensures workers stay running:
sudo apt install supervisor -y
sudo nano /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel/current/artisan queue:work redis \
--sleep=3 \
--tries=3 \
--max-time=3600 \
--queue=high,default,low
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=3
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
stdout_logfile_maxbytes=50MB
stopwaitsecs=3600
sudo mkdir -p /var/log/supervisor
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
Step 8: Install Laravel Horizon for Queue Monitoring
Laravel Horizon provides a beautiful dashboard for monitoring your Redis queues — throughput, failed jobs, wait times, and worker metrics:
cd /var/www/laravel/current
sudo -u www-data composer require laravel/horizon
sudo -u www-data php artisan horizon:install
sudo -u www-data php artisan horizon:publish
Configure Horizon in config/horizon.php to define worker pools:
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 5,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'queue' => ['high', 'default', 'low'],
],
],
],
Create a Supervisor configuration for Horizon:
sudo nano /etc/supervisor/conf.d/laravel-horizon.conf
[program:laravel-horizon]
process_name=%(program_name)s
command=php /var/www/laravel/current/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-horizon.log
stopwaitsecs=3600
sudo supervisorctl reread && sudo supervisorctl update
Access the Horizon dashboard at https://yourdomain.com/horizon (restrict access in app/Providers/HorizonServiceProvider.php using IP allowlisting or authentication).
Step 9: Set Up Laravel Scheduler
Laravel’s task scheduler runs recurring jobs. Add a single cron entry to trigger it every minute:
sudo crontab -u www-data -e
* * * * * cd /var/www/laravel/current && php artisan schedule:run >> /dev/null 2>&1
Step 10: Zero-Downtime Deployments
The atomic deployment pattern uses symlinks to switch between releases instantly — no downtime:
sudo nano /usr/local/bin/deploy-laravel.sh
#!/bin/bash
set -e
APP_DIR=/var/www/laravel
REPO=https://github.com/yourorg/myapp.git
RELEASE_DIR=$APP_DIR/releases/$(date +%Y%m%d%H%M%S)
CURRENT=$APP_DIR/current
SHARED=$APP_DIR/shared
# Create shared directories (persist across deployments)
mkdir -p $SHARED/storage/app/public
mkdir -p $SHARED/storage/framework/{cache,sessions,views}
mkdir -p $SHARED/storage/logs
# Clone new release
sudo -u www-data git clone $REPO $RELEASE_DIR
cd $RELEASE_DIR
# Link shared .env and storage
sudo -u www-data ln -nfs $SHARED/.env .env
sudo -u www-data rm -rf storage
sudo -u www-data ln -nfs $SHARED/storage storage
# Install dependencies
sudo -u www-data composer install --no-dev --optimize-autoloader -q
# Run migrations
sudo -u www-data php artisan migrate --force
# Clear and rebuild caches
sudo -u www-data php artisan config:cache
sudo -u www-data php artisan route:cache
sudo -u www-data php artisan view:cache
sudo -u www-data php artisan event:cache
# Atomic symlink swap (instant cutover)
sudo -u www-data ln -nfs $RELEASE_DIR $CURRENT
# Reload PHP-FPM (resets OPcache)
sudo systemctl reload php8.2-fpm
# Restart queue workers to pick up new code
sudo supervisorctl restart laravel-horizon
# Clean up old releases (keep last 5)
ls -dt $APP_DIR/releases/*/ | tail -n +6 | xargs rm -rf
echo "Deployment to $RELEASE_DIR complete."
sudo chmod +x /usr/local/bin/deploy-laravel.sh
The symlink swap is atomic at the filesystem level — Nginx begins serving the new release the moment the ln -nfs command completes, with zero downtime between releases.
Getting Started
A Laravel application with queue workers, Horizon, and Redis caching runs comfortably on a 2 vCPU / 2 GB RAM VPS for most workloads. Ubuntu VPS plans at VPS.DO provide the clean Ubuntu 22.04 LTS environment this guide requires, with NVMe storage for fast OPcache and Redis operations, and KVM virtualization for full PHP extension compatibility.
Conclusion
A production Laravel deployment on a VPS — with PHP-FPM OPcache, Redis for sessions and queues, Supervisor-managed workers, Horizon for queue visibility, and atomic zero-downtime deployments — is a robust, maintainable infrastructure foundation. The deployment script turns each code push into a consistent, repeatable process that safely migrates the database, caches configuration, and switches traffic to the new release without any service interruption.