Jay GajjarSenior Software Engineer

How to deploy Laravel on cPanel or a VPS: shared hosting vs Nginx and PHP-FPM (2026)

How to deploy Laravel on cPanel shared hosting or a VPS: document root, .env, permissions, cron, Supervisor queue workers and zero-downtime releases.

By Jay Gajjar9 min read

To deploy Laravel on cPanel, you upload the app outside public_html, make the domain serve only Laravel's public/ folder, set production values in .env, run composer install --no-dev, migrations and php artisan optimize, and add one cron job for the scheduler. On a VPS the steps are the same, plus you configure Nginx and PHP-FPM yourself and keep queue workers alive with Supervisor. The details are where deployments go wrong: a document root that exposes .env, a cron job running the wrong PHP version, or queue workers still executing last week's code.

This guide covers both routes for Laravel 13 (current as of October 2026), with the commands I use. It is checked against the official Laravel deployment (opens in a new tab), queues (opens in a new tab) and scheduling (opens in a new tab) docs.

Quick answer: the deployment checklist

  1. PHP 8.3 or newer (Laravel 13's minimum) with the required extensions.
  2. Code outside the web root. The domain's document root points to public/, never the project root.
  3. .env with APP_ENV=production, APP_DEBUG=false, a generated APP_KEY and real database credentials.
  4. composer install --no-dev --optimize-autoloader, front-end assets built with npm run build.
  5. php artisan migrate --force, php artisan storage:link, php artisan optimize.
  6. storage/ and bootstrap/cache/ writable by the user PHP runs as.
  7. One cron entry: * * * * * cd /path && php artisan schedule:run. Every scheduled job, from reports to automation scripts, runs from it, including the queued scrapers in my PHP web scraping guide.
  8. Queue workers under Supervisor (VPS) or a scheduler-driven workaround (shared hosting).
  9. After every deploy: php artisan optimize again and php artisan reload so workers pick up new code.

cPanel shared hosting vs a VPS: which one fits?

Both can run Laravel well. The difference is how much control you have over long-running processes.

cPanel shared hosting VPS (Nginx and PHP-FPM)
SSH and Composer Often available via Terminal, sometimes disabled Full root access
PHP version Picked per domain in MultiPHP Manager or "Select PHP Version" Any version you install
Document root Main domain is fixed to public_html Whatever you configure
Cron Cron Jobs screen; some hosts limit frequency Full crontab
Queue workers No Supervisor; workarounds only Supervisor, Horizon
Redis, Horizon, WebSockets Rarely available Yes
Who maintains the server The host You (or someone you hire)

My rule of thumb when clients ask whether to deploy Laravel on cPanel or a VPS: a brochure site, a small internal tool or an app with light background work is fine on good shared hosting. Anything that depends on queues (sending email in bulk, imports, webhooks, PDF generation) or needs predictable performance belongs on a VPS. Scheduled data pipelines are a core part of the brokerage risk platform I worked on, and that kind of workload is exactly where shared hosting starts to hurt.

Before you deploy: production settings

Check the server meets Laravel 13's requirements: PHP 8.3+, with the Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer and XML extensions. Then prepare the production .env. These are the values that matter most:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=app_db
DB_USERNAME=app_user
DB_PASSWORD=change-me

SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database
LOG_LEVEL=warning

APP_DEBUG=true in production is one of the most serious mistakes you can make: error pages then show environment details to anyone who triggers an exception. It is the first thing I check on an inherited app. The new Laravel skeleton defaults to SQLite, so set DB_CONNECTION=mysql explicitly if you use MySQL.

One more rule from the docs: once you run config:cache, the .env file is no longer loaded, so env() returns null anywhere outside the config/ files. Search your app for env( calls in controllers or services before you cache.

How to deploy Laravel on cPanel, step by step

1. Set the PHP version and extensions

In cPanel, open MultiPHP Manager (or Select PHP Version on CloudLinux hosts) and choose PHP 8.3 or newer for the domain. Enable any missing extensions there. Then check from Terminal that the command line uses the same version:

php -v
which php

The command-line PHP is often a different version from the one serving the website. On EasyApache 4 servers, each version's binary usually lives at a path like /opt/cpanel/ea-php83/root/usr/bin/php. Use that full path in cron jobs if php -v shows the wrong version.

2. Put the code outside public_html

Upload or clone the project to a folder in your home directory, for example /home/username/myapp. cPanel's Git Version Control can clone from GitHub or GitLab, or you can use SFTP. The point is that app/, config/, vendor/ and .env must not sit anywhere the web server can serve.

3. Point the domain at Laravel's public folder

This is the step most guides get wrong. Your options, in order of preference:

  • Addon domain or subdomain with its own document root. In cPanel's Domains screen, set the document root to myapp/public. Many servers restrict document roots to inside public_html (a WHM setting that is on by default), in which case this option only works if your host turns that off.
  • Main domain via a symlink. cPanel does not let you change the main domain's document root. If your host allows symlinks, rename public_html and replace it with a link to the app's public folder:
mv ~/public_html ~/public_html_old
ln -s ~/myapp/public ~/public_html
  • Copy public/ into public_html. If symlinks are not allowed, copy the contents of public/ (including .htaccess) into public_html, then edit public_html/index.php so the paths point at the app and tell Laravel where its public folder now is:
<?php

use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

define('LARAVEL_START', microtime(true));

if (file_exists($maintenance = __DIR__.'/../myapp/storage/framework/maintenance.php')) {
    require $maintenance;
}

require __DIR__.'/../myapp/vendor/autoload.php';

/** @var Application $app */
$app = require_once __DIR__.'/../myapp/bootstrap/app.php';

$app->usePublicPath(__DIR__);

$app->handleRequest(Request::capture());

What you must not do is place the whole project inside public_html and point a subdomain at public_html/myapp/public. The main domain then serves public_html, and example.com/myapp/.env becomes downloadable. It is one of the most common ways database passwords leak.

4. Install dependencies and configure .env

From Terminal, inside the app folder:

cd ~/myapp
composer install --no-dev --optimize-autoloader --no-interaction
cp .env.example .env
php artisan key:generate
chmod 600 .env

Create the database and user in MySQL Databases (cPanel prefixes both with your account name), put the credentials in .env, then run:

php artisan migrate --force
php artisan storage:link
php artisan optimize

If your plan has no SSH at all, build vendor/ on your machine or in CI with the same PHP version as the server, and run composer check-platform-reqs there first. Build front-end assets locally with npm run build and upload the public/build folder, because shared hosts rarely have Node.js.

storage:link creates public/storage. If you copied public/ into public_html, the usePublicPath() call above makes the link land in the right place. If the host disables PHP's symlink() function, create it by hand with ln -s ~/myapp/storage/app/public ~/public_html/storage.

5. Permissions on cPanel

On most cPanel servers PHP runs as your own account user, so standard permissions work: 755 for folders and 644 for files, with storage/ and bootstrap/cache/ writable by you. Never set 777. It is unnecessary and dangerous on shared servers.

6. Laravel scheduler cron job

In Cron Jobs, add one entry that runs every minute:

* * * * * cd /home/username/myapp && /opt/cpanel/ea-php83/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1

Your scheduled tasks themselves live in routes/console.php. Run php artisan schedule:list to confirm Laravel sees them. If your host only allows cron every five minutes or more, tasks scheduled more often will not run on time.

7. Queues without Supervisor

Shared hosting cannot keep a queue:work process alive. The practical workaround is to let the scheduler start a short-lived worker every minute that exits once the queue is empty:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('queue:work --stop-when-empty --max-time=50')
    ->everyMinute()
    ->withoutOverlapping(5)
    ->runInBackground();

withoutOverlapping(5) makes the overlap lock expire after five minutes instead of the default 24 hours, so a killed worker cannot block the queue for a day. Jobs can wait up to a minute before they start, and hosts may kill processes that use too much CPU. If either is a problem, it is time for a VPS.

How to deploy Laravel on a VPS with Nginx and PHP-FPM

These steps assume Ubuntu 24.04 LTS, whose default repositories ship PHP 8.3.

1. Install the stack

sudo apt update
sudo apt install nginx php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
  php8.3-xml php8.3-curl php8.3-zip php8.3-intl php8.3-bcmath unzip git supervisor

Install Composer from getcomposer.org (opens in a new tab), and MySQL or MariaDB on the same server or a managed database.

2. One user for code, PHP, cron and workers

The most common VPS permission bug: the cron job or queue worker runs as deploy, creates storage/logs/laravel.log with 644 permissions, and then PHP-FPM, running as www-data, cannot write to it. My fix is to run everything as one user. Create a deploy user, then set the PHP-FPM pool to run as that user in /etc/php/8.3/fpm/pool.d/www.conf:

user = deploy
group = deploy
listen.owner = www-data
listen.group = www-data

Nginx still runs as www-data and only needs to read the public/ folder and connect to the socket. Restart PHP-FPM with sudo systemctl restart php8.3-fpm.

3. Nginx server block

Start from the configuration in the Laravel docs. The parts that matter are the root pointing at public, the try_files line and $realpath_root:

server {
    listen 80;
    server_name example.com;
    root /var/www/example.com/current/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

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

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

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

Test with sudo nginx -t, reload, then add HTTPS with sudo certbot --nginx -d example.com. The $realpath_root variable matters for the zero-downtime setup below: it resolves the current symlink, so PHP sees the real release path.

4. Supervisor for Laravel queue workers

Create /etc/supervisor/conf.d/laravel-worker.conf, based on the example in the Laravel queue docs:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/example.com/current/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deploy
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/example.com/shared/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"

Set stopwaitsecs higher than your longest job, or Supervisor will kill jobs mid-run during restarts. Start with a small numprocs and raise it when the queue backs up. If you use Redis queues, Laravel Horizon replaces this block with a single horizon process.

5. Cron for the scheduler

Add the scheduler to the deploy user's crontab with crontab -e:

* * * * * cd /var/www/example.com/current && php artisan schedule:run >> /dev/null 2>&1

Zero-downtime deployment basics

Running git pull in the live folder leaves a window where code and vendor/ do not match. The standard fix is release folders and a symlink: build each release in its own directory, share .env and storage/ between releases, and switch the current symlink only when the new release is ready.

#!/usr/bin/env bash
set -euo pipefail

APP=/var/www/example.com
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"

git clone --depth 1 --branch main git@github.com:you/app.git "$RELEASE"
cd "$RELEASE"

ln -s "$APP/shared/.env" .env
rm -rf storage && ln -s "$APP/shared/storage" storage

composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build

php artisan storage:link
php artisan migrate --force
php artisan optimize

ln -sfn "$RELEASE" "$APP/current"
sudo systemctl reload php8.3-fpm
php artisan reload

ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf

Before the first run, create shared/.env and a shared/storage folder with the same subfolders as the repository's storage/ (app/public, framework/cache, framework/sessions, framework/views, logs). A few rules make this safe:

  • Migrations must be backward compatible. They run before the switch, so the old code briefly runs against the new schema. Add columns first, remove them in a later release.
  • php artisan reload restarts queue workers (queue:restart) and interrupts running scheduler processes, so neither keeps running old code. Supervisor starts fresh workers from the new release.
  • Reloading PHP-FPM clears OPcache. The deploy user needs a sudoers rule allowing only that command.
  • Rollback is pointing current at the previous release and reloading again, as long as the migrations allow it.

Tools like Deployer, Laravel Forge or Laravel Cloud automate the same pattern. It is worth knowing what they do underneath, because that is what you debug when a deploy fails.

After deploying: quick checks

  • Visit /up, Laravel's built-in health route. It returns 200 when the app boots.
  • Trigger a test job and watch it leave the jobs table.
  • Run php artisan schedule:list and check the next run times.
  • Confirm https://example.com/.env returns 403 or 404, never the file.
  • Tail storage/logs/laravel.log for a few minutes under real traffic.

For speed work after launch, see my Laravel performance checklist, or my Laravel performance optimization service if you want it measured and fixed for you. If you are deploying as part of a version jump, my Laravel 13 upgrade guide covers the code side.

Frequently asked questions

Can I deploy Laravel on shared hosting without SSH?

Yes, but it is fragile. Build vendor/ and front-end assets on your own machine with the same PHP version, upload everything except .env by SFTP, and run migrations through a protected one-off route or the host's tools. Every later deploy repeats the manual steps, so get SSH if the host offers it.

Why does my Laravel site show a 500 error after uploading to cPanel?

The usual causes are a PHP version below Laravel's minimum, a missing vendor/ folder, an empty APP_KEY, or storage/ not being writable. Check storage/logs/laravel.log first, then the domain's error log in cPanel.

Do I need to run php artisan optimize on every deploy?

Yes. It caches config, events, routes and views. If you change .env or config files later without re-running it, Laravel keeps using the old cached values, so run php artisan optimize (or optimize:clear followed by optimize) after every change.

How do I run Laravel queue workers on cPanel?

cPanel shared hosting has no Supervisor, so schedule queue:work --stop-when-empty every minute through the Laravel scheduler, as shown above. It works for light workloads. For anything time-sensitive, use a VPS with Supervisor.

Should I choose shared hosting or a VPS for Laravel?

Choose shared hosting for small sites with little background work. Choose a VPS once you depend on queues, need Redis, or want control over PHP versions and performance.

If you would rather hand the server setup, deployment pipeline and ongoing updates to someone else, that is part of my Laravel and PHP maintenance work.