Deploy Laravel on VPS Ubuntu: Nginx + PHP + MySQL Setup

Laravel is the most popular PHP framework in Southeast Asia. To deploy it correctly on a VPS you need to set up the full stack: Nginx + PHP-FPM + MySQL + Composer, configure file permissions, .env, and a Queue Worker. This guide takes you from a blank Ubuntu 22.04 VPS to a running production app — step by step.

Recommended VPS spec: 2 GB RAM or more, 30 GB+ SSD, Ubuntu 22.04 LTS for a mid-size Laravel application.

Understanding the Laravel Stack on VPS

Before diving into installation commands, it helps to understand how the different components work together. Every user request passes through multiple layers on the server:

  1. Nginx — Accepts HTTP/HTTPS connections from the internet and acts as a reverse proxy forwarding PHP requests to PHP-FPM.
  2. PHP-FPM (FastCGI Process Manager) — Processes PHP code and runs the Laravel application logic.
  3. MySQL — Stores all application data: users, posts, orders, settings.
  4. Laravel Application — Receives requests via public/index.php, routes them through Controllers and Models, then returns a response.
  5. Queue Worker (optional) — Handles heavy background tasks such as sending emails, resizing images, or exporting reports without blocking the request cycle.

Understanding this flow makes debugging far easier. If the page fails to load, check Nginx logs first. If you see a PHP error, inspect the PHP-FPM log. If data is not saving, look at MySQL errors.

Why VPS beats shared hosting for Laravel: Shared hosting restricts PHP extensions, blocks Artisan commands via terminal, and prevents running persistent background processes like Queue Workers. A VPS gives you full root access, the ability to install any extension, customise php.ini, and run workers 24 hours a day.

Harden the VPS Before Deploying Laravel

Before installing any software, secure the server. A fresh VPS connected to the internet will face automated port scans and brute-force attempts within minutes.

Configure UFW Firewall

# Allow only essential ports
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

Create a Non-Root Deploy User

sudo adduser deployer
sudo usermod -aG sudo deployer

# Copy your SSH key to the new user
sudo rsync --archive --chown=deployer:deployer ~/.ssh /home/deployer

Disable Password Login Over SSH

sudo nano /etc/ssh/sshd_config
# Set these two lines:
PasswordAuthentication no
PermitRootLogin no

sudo systemctl restart sshd

Always verify that key-based login works before disabling password authentication to avoid locking yourself out.

Step 1 — Update the System and Install Nginx

# Update package lists
sudo apt update && sudo apt upgrade -y

# Install Nginx
sudo apt install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx

Step 2 — Install PHP 8.2 + Laravel Extensions

# Add PHP PPA (Ubuntu 22.04)
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

# Install PHP 8.2 and all extensions Laravel requires
sudo apt install php8.2 php8.2-fpm php8.2-mysql php8.2-xml \
  php8.2-curl php8.2-mbstring php8.2-zip php8.2-bcmath \
  php8.2-tokenizer php8.2-gd php8.2-intl -y

# Verify the version
php -v

Step 3 — Install MySQL

sudo apt install mysql-server -y
sudo mysql_secure_installation

# Create a database and user for Laravel
sudo mysql -u root -p
CREATE DATABASE laravel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'laravel_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
GRANT ALL PRIVILEGES ON laravel_db.* TO 'laravel_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Step 4 — Install Composer

curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version

Step 5 — Upload Your Laravel Project

Transfer your project to the VPS using Git or SCP:

# Clone from Git (recommended)
cd /var/www
sudo git clone https://github.com/yourusername/your-laravel-app.git myapp
sudo chown -R www-data:www-data /var/www/myapp

# Install Composer dependencies (production mode)
cd /var/www/myapp
sudo -u www-data composer install --no-dev --optimize-autoloader

Step 6 — Configure .env and Application Key

cp .env.example .env
nano .env
# Edit the following values:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=StrongPassword123!

# Generate application key
php artisan key:generate

# Run database migrations
php artisan migrate --force

Step 7 — Set Storage Permissions

sudo chown -R www-data:www-data /var/www/myapp/storage
sudo chown -R www-data:www-data /var/www/myapp/bootstrap/cache
sudo chmod -R 775 /var/www/myapp/storage
sudo chmod -R 775 /var/www/myapp/bootstrap/cache

# Create the public storage symlink
php artisan storage:link

Step 8 — Configure Nginx Virtual Host

sudo nano /etc/nginx/sites-available/myapp

# Paste this configuration:
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    root /var/www/myapp/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht { deny all; }
}

# Enable the site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 9 — Install SSL with Certbot

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Certbot will configure HTTPS and auto-renewal automatically

Step 10 — Configure Queue Worker (if using Queues)

# Create a Supervisor config for the queue worker
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/myapp/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/myapp/storage/logs/worker.log

# Reload Supervisor
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

Step 11 — Optimize for Production

# Cache config, routes, and views
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Optimize Composer autoloader
composer dump-autoload --optimize

Set Up Laravel Scheduler with Cron

Laravel's built-in Task Scheduler lets you manage all scheduled tasks in PHP code rather than creating many individual Cron jobs. Only one Cron entry is needed on the server:

# Edit the crontab for the www-data user
sudo crontab -u www-data -e

# Add this single line to run the scheduler every minute
* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1

With this in place, every task defined in your application's scheduler (daily emails, weekly cleanups, hourly data syncs) will fire automatically at the right time. Example tasks:

# routes/console.php (Laravel 10+)
use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send-digest')->dailyAt('08:00');
Schedule::command('logs:clean')->weekly();
Schedule::command('cache:prune-stale-tags')->hourly();

Monitoring and Debugging Your Laravel Deployment

After going live, monitoring ensures the application stays healthy. Laravel's logging system writes all errors to storage/logs/laravel.log by default.

Watch Logs in Real Time

tail -f /var/www/myapp/storage/logs/laravel.log

Common Problems and Fixes

Symptom Likely Cause Fix
500 Internal Server Error Wrong permissions or missing .env values Check storage/ permissions and .env completeness
404 Not Found Nginx root or try_files misconfigured Verify the root path points to public/
502 Bad Gateway PHP-FPM not running sudo systemctl restart php8.2-fpm
Jobs stuck in queue Supervisor not running sudo supervisorctl start laravel-worker:*

Managing Multiple Environments on a VPS

Production Laravel applications typically span at least three environments: Local (developer machines), Staging (QA testing), and Production (the live server). Keeping environments separate prevents test data from contaminating real data and lets your team verify changes safely before they reach users.

Environment File Structure

# How to structure environment files
.env              # Active config (never commit — listed in .gitignore)
.env.example      # Template for onboarding developers (commit this)
.env.staging      # Staging values (server-side only, not committed)
.env.production   # Production values (server-side only, not committed)

A Well-Structured Production .env

APP_NAME="MyApp"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
LOG_CHANNEL=daily
LOG_LEVEL=error

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_prod
DB_USERNAME=laravel_prod_user
DB_PASSWORD=VeryStrongProductionPassword!

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

Golden rule: Every .env file must be listed in .gitignore. Only .env.example (with no real secrets) should ever be committed. A leaked production .env can expose database credentials and encryption keys instantly.

Laravel Horizon: Queue Dashboard on VPS

Laravel Horizon is the official queue monitoring dashboard built by the Laravel team. It gives you real-time visibility into job throughput, processing times, failure rates, and worker balance — making queue debugging far faster than reading raw log files.

Install Laravel Horizon

composer require laravel/horizon
php artisan horizon:install

Configure Workers in config/horizon.php

'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
        ],
    ],
    'local' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
        ],
    ],
],

Supervisor Config for Horizon

sudo nano /etc/supervisor/conf.d/laravel-horizon.conf

[program:laravel-horizon]
process_name=%(program_name)s
command=php /var/www/myapp/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/myapp/storage/logs/horizon.log
stopwaitsecs=3600

sudo supervisorctl reread && sudo supervisorctl update
sudo supervisorctl start laravel-horizon

Restrict the Horizon Dashboard

// app/Providers/HorizonServiceProvider.php
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return in_array($user->email, [
            '[email protected]',
        ]);
    });
}

After setup, access the Horizon dashboard at https://yourdomain.com/horizon. You can see pending, processing, and failed jobs — and retry failures directly from the UI without SSH access.

Rate Limiting and API Abuse Protection

Production applications need protection against bots and clients that hammer the API. Laravel ships with built-in rate limiting via the throttle middleware, backed by Redis for distributed counting across multiple queue workers or processes.

Define Rate Limits in RouteServiceProvider

// In App\Providers\RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->input('email')),
        Limit::perMinute(20)->by($request->ip()),
    ];
});

Add Nginx-Level Rate Limiting as First Defence

# In /etc/nginx/nginx.conf, http block
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

# In your Laravel server block
location /api/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    include fastcgi_params;
}

location /login {
    limit_req zone=login burst=3 nodelay;
    limit_req_status 429;
    try_files $uri $uri/ /index.php?$query_string;
}
Layer Tool Benefit
Network UFW Firewall Block ports before traffic reaches Nginx
Web Server Nginx limit_req Rate-limit before PHP is invoked
Application Laravel throttle Per-user limits with Redis tracking
Process Fail2ban Auto-ban IPs that trigger too many 429s

Layering protection at Nginx and Laravel simultaneously (Defence in Depth) means an abusive client is rejected before PHP-FPM is even invoked, saving CPU and RAM during traffic spikes.

Zero-Downtime Code Updates

When pushing new code to production, follow this sequence to avoid errors reaching users during the update window:

# 1. Enable maintenance mode
php artisan down --retry=60

# 2. Pull latest code
git pull origin main

# 3. Install or update dependencies
composer install --no-dev --optimize-autoloader

# 4. Run pending migrations
php artisan migrate --force

# 5. Clear and rebuild caches
php artisan optimize:clear
php artisan optimize

# 6. Restart queue workers
sudo supervisorctl restart laravel-worker:*

# 7. Take the site back online
php artisan up

Using php artisan down before running migrations prevents users from hitting database errors during structural changes, such as adding or removing columns.

Pre-launch Checklist: APP_DEBUG=false ✓ → APP_ENV=production ✓ → SSL Active ✓ → Storage Permissions ✓ → Queue Worker Running ✓ → Config Cache ✓ → Cron job set ✓ — Ready for production.

⚠️ Security reminder: Never commit your .env file to Git. Enable UFW to close unnecessary ports and consider changing the default SSH port to reduce brute-force exposure.

Laravel-Ready VPS Starting at an Affordable Price

AsiaGB VPS ships with Ubuntu 22.04 LTS, full root access, SSD, and a Thai IP address — everything you need to deploy Laravel in minutes.

View VPS Plans