Deploy Laravel to a VPS: queues, scheduler and zero-downtime releases
How to deploy Laravel to a VPS on Ubuntu 24.04: PHP-FPM, nginx, .env, queue workers, the scheduler and zero-downtime releases, by hand or with ZoPanel.

Key takeaways
- Laravel 13 needs PHP 8.3 or newer, so Ubuntu 24.04's own php8.3 packages are enough.
- Point nginx at the project's public folder and use $realpath_root, so a symlinked release switch is picked up cleanly.
- Queue workers keep old code in memory: run php artisan queue:restart on every deploy and let systemd start them again.
- Zero downtime means building each release in its own folder, sharing storage and .env, and swapping a current symlink in one atomic step.
To deploy Laravel to a VPS you need PHP-FPM with the extensions Laravel requires, nginx pointing at the project's public folder, a database, a production .env, and three things people often forget: a queue worker kept alive by systemd, one cron entry for the scheduler, and a release process that does not take the site down. The safe pattern is to build each release in its own folder, share storage and .env between releases, and switch a current symlink in one atomic step. By hand that is about an hour the first time; with a panel that understands Laravel, a few minutes.
This guide shows both on Ubuntu 24.04: every command for the manual way, then the same job in ZoPanel. If you have never set up nginx and PHP-FPM, read how to install a LEMP stack on Ubuntu first; this post builds on it.
Which PHP version does Laravel need?
According to Laravel's support policy, at the time of writing (October 2026):
| Laravel | Supported PHP | Released | Bug fixes until | Security fixes until |
|---|---|---|---|---|
| 13 | 8.3 - 8.5 | March 17, 2026 | Q3 2027 | March 17, 2028 |
| 12 | 8.2 - 8.5 | February 24, 2025 | August 13, 2026 | February 24, 2027 |
| 11 | 8.2 - 8.4 | March 12, 2024 | September 3, 2025 | March 12, 2026 |
Laravel 11 is end of life and Laravel 12 now gets security fixes only, so new projects should start on Laravel 13. Ubuntu 24.04 ships PHP 8.3 in its own repositories, which meets Laravel 13's minimum. If you need 8.4, or several versions side by side, add Ondřej Surý's PPA as described in running multiple PHP versions.
How to deploy Laravel to a VPS by hand
The examples use an app called myapp, the domain example.com and the folder /var/www/myapp.
Step 1: Install PHP, Composer and the database
Laravel's deployment docs list the required extensions (ctype, cURL, DOM, fileinfo, filter, hash, mbstring, OpenSSL, PCRE, PDO, session, tokenizer, XML). On Ubuntu most of them come with php8.3-common; these packages cover the rest plus MySQL, Redis and common needs:
sudo apt update
sudo apt install -y nginx mariadb-server git unzip \
php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring php8.3-xml \
php8.3-curl php8.3-zip php8.3-bcmath php8.3-intl php8.3-redis
Install Composer with its installer, checking the published signature first:
EXPECTED="$(curl -fsSL https://composer.github.io/installer.sig)"
curl -fsSL https://getcomposer.org/installer -o composer-setup.php
echo "$EXPECTED composer-setup.php" | sha384sum -c -
sudo php composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
Create a database and user (after sudo mariadb-secure-installation):
CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myapp'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON myapp.* TO 'myapp'@'localhost';
Step 2: A user, a PHP-FPM pool and the folder layout
Run the app as its own user, not www-data, so it can write storage and bootstrap/cache without loosening permissions for every site on the server:
sudo adduser --disabled-password --gecos "" myapp
sudo usermod -aG myapp www-data # nginx may read public files
sudo mkdir -p /var/www/myapp/{releases,shared}
sudo chown -R myapp:myapp /var/www/myapp
sudo chmod 750 /var/www/myapp
Create /etc/php/8.3/fpm/pool.d/myapp.conf:
[myapp]
user = myapp
group = myapp
listen = /run/php/php8.3-fpm-myapp.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
Then sudo systemctl reload php8.3-fpm. The layout looks like this:
/var/www/myapp/
releases/20261021093000/ one folder per deploy
shared/.env production settings, kept forever
shared/storage/ uploads, logs, sessions, kept forever
current -> releases/20261021093000
Put your production settings in shared/.env (APP_ENV=production, APP_DEBUG=false, APP_URL=https://example.com, the DB_* values) and generate APP_KEY once with php artisan key:generate --show. Keep that key: changing it logs everyone out and makes encrypted data unreadable. Copy the repository's storage folder to shared/storage on the first deploy.
Step 3: The nginx server block
Start from Laravel's official nginx example, with the root on current/public and your own socket. Save it as /etc/nginx/sites-available/myapp:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/myapp/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.3-fpm-myapp.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
$realpath_root matters here: it resolves the current symlink, so PHP and OPcache see the real release path and never mix files from two releases. Enable the site, then add free SSL with Let's Encrypt:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
More on renewals and wildcards in free SSL with Let's Encrypt.
Step 4: A zero-downtime deploy script
Save this as /var/www/myapp/deploy.sh, owned by myapp, and run it as that user:
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/myapp
REL="$APP/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main git@github.com:you/myapp.git "$REL"
cd "$REL"
rm -rf storage && ln -s "$APP/shared/storage" storage
ln -s "$APP/shared/.env" .env
composer install --no-dev --optimize-autoloader --no-interaction
# npm ci && npm run build # if you build Vite assets on the server
php artisan storage:link
php artisan optimize # config, events, routes and views
php artisan migrate --force
ln -sfn "$REL" "$APP/current.new"
mv -Tf "$APP/current.new" "$APP/current" # atomic swap
sudo /usr/bin/systemctl reload php8.3-fpm
php artisan queue:restart
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf # keep 5
Why each part:
- New folder per release.
composer installand the cache build happen while the old release keeps serving visitors. If any command fails,set -estops the script beforecurrentchanges. php artisan optimizecaches config, events, routes and views, as the deployment docs recommend. Once config is cached,.envis not read at runtime, so callenv()only insideconfig/files.migrate --forceruns before the switch, so the old code briefly runs against the new schema. Write migrations both releases can live with: add a column first, drop it in a later release.mv -Treplaces the symlink with a singlerename(), so no request ever sees a missingcurrent.ln -sfnalone is not guaranteed to be atomic.- Reloading PHP-FPM gracefully restarts its workers, which clears OPcache and PHP's realpath cache. Allow it in sudoers for this one command:
myapp ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm.
Rolling back is the same swap pointed at the previous folder, followed by the reload and queue:restart. The database is not rolled back. To run this script on every git push, with GitHub Actions or a verified webhook, see auto deploy from GitHub to a VPS.
Step 5: Queue workers under systemd
Laravel's queue docs are blunt: workers are long-lived processes that keep the booted application in memory, so they do not notice new code until they restart. queue:restart asks them to exit after their current job; a process manager must then start them again. The docs use Supervisor, but systemd is already on the server. Create /etc/systemd/system/myapp-queue.service:
[Unit]
Description=myapp queue worker
After=network.target mariadb.service
[Service]
User=myapp
Group=myapp
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/php /var/www/myapp/current/artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=3
TimeoutStopSec=3600
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-queue
Restart=always is essential, because a worker stopped by queue:restart exits cleanly and on-failure would leave it down. Each restart resolves current again, so the worker picks up the new release. queue:restart stores its signal in the cache, so a cache driver must be configured. TimeoutStopSec plays the role of Supervisor's stopwaitsecs: set it above your longest job.
Step 6: The scheduler
The scheduler needs one cron entry that runs every minute. Add it with sudo crontab -u myapp -e:
* * * * * cd /var/www/myapp/current && php artisan schedule:run >> /dev/null 2>&1
php artisan schedule:work also exists, but Laravel documents it for running the scheduler locally; on a server, cron is the documented method. If you use sub-minute tasks, add php artisan schedule:interrupt at the end of the deploy script so a running schedule:run stops using the old code.
The checklist
| Item | Manual setup | Breaks if forgotten |
|---|---|---|
| nginx root | current/public, $realpath_root |
Source and .env exposed, or stale files after a switch |
.env and storage |
In shared/, linked into each release |
Lost uploads, sessions and logs on every deploy |
APP_KEY |
Generated once, never changed | Everyone logged out, encrypted data unreadable |
| Caches | php artisan optimize in each release |
Slower requests; .env edits seem ignored when cached |
| Migrations | migrate --force before the switch |
Code expects columns that do not exist yet |
| Queue | systemd unit plus queue:restart |
Jobs run old code or stop entirely |
| Scheduler | One cron line, every minute | Scheduled emails, cleanups and reports never run |
| SSL | certbot --nginx |
Browser warnings, mixed APP_URL links |
How to deploy Laravel with ZoPanel
ZoPanel runs Laravel as a PHP website: nginx serves the public folder and PHP runs in the hosting account's own PHP-FPM pool, under the account's own Linux user and resource limits. It offers two routes, both described in the Laravel docs:
- One-click installer for a new, empty project: it runs
composer create-project laravel/laravel, creates a MariaDB database, writes.env, then runskey:generate,migrate --forceandstorage:link. - Git deploy for your own repository. ZoPanel detects Laravel from
artisanorlaravel/framework, picks the PHP version fromrequire.phpincomposer.json, and proposescomposer install --no-dev --optimize-autoloaderplusconfig:cache,route:cacheandview:cache. With abuildscript inpackage.json, it addsnpm ci && npm run buildfor Vite.
Git deploy follows the same pattern as the script above: each build goes into releases/<timestamp>, storage and .env are persistent and linked in, and current switches in one step with a graceful PHP-FPM reload. If install or build fails, the previous release keeps running. It generates APP_KEY once if missing, sets APP_ENV=production and APP_DEBUG=false, and uses the account's Redis for sessions and cache when available. Releases keeps the last 5 builds with one-click rollback, and pushes to your branch can deploy automatically through a signed webhook from GitHub, GitLab or Gitea. Details are in Git deploy.
Where ZoPanel is weaker, honestly:
- Migrations are not automatic. You add
php artisan migrate --forceto the build command yourself. - No long-running queue worker for PHP sites. Git deploy defaults to
QUEUE_CONNECTION=sync. For background jobs, ZoPanel's docs use a cron job runningqueue:work --stop-when-empty --max-time=55every minute. That suits light and medium queues; if you need Horizon or many always-on workers, the manual systemd setup above, or Forge, fits better. - The scheduler is a cron job you add under Cron Jobs, pointing at
current. - Rollback switches code, not the database. Write migrations that the previous release can tolerate.
Forge, Envoyer, Deployer or a panel?
Laravel Forge is the official choice and very good at this: it provisions servers (its own Laravel VPS or your cloud provider), installs and manages nginx, PHP, MySQL, Redis and the rest of the stack, and includes zero-downtime deployments on a single server. At the time of writing (October 2026), plans cost $12, $19 and $39 per month. Envoyer ($10 to $50 per month) adds zero-downtime deploys of one project to several servers; Forge's FAQ says new Forge subscriptions no longer need it unless you deploy to multiple servers. Deployer is a free, MIT-licensed CLI with Laravel support that does the same releases-and-symlink dance from your machine or CI.
ZoPanel fits a different case: it is installed on your own server and licensed per server (free, yearly or one-time) instead of a monthly subscription, and the same panel also hosts WordPress sites, email, DNS and customer accounts, each isolated in its own user and resource limits. If you run only Laravel apps and want managed workers, Forge is the more complete Laravel tool.
Conclusion
Deploying Laravel to a VPS comes down to a correct nginx root, a shared storage and .env, cached config, migrations before the switch, an atomic current symlink, and two processes outside the request cycle: the queue worker and the scheduler. If you would rather have releases, rollbacks and SSL handled for you, try ZoPanel's Free plan (3 hosting accounts, 10 websites, 10 databases, no time limit) on a test VPS and deploy your own repository.
Frequently asked questions
Which PHP version should I use for Laravel in 2026?
Laravel 13 supports PHP 8.3 to 8.5 and Laravel 12 supports 8.2 to 8.5, per the official support policy. On Ubuntu 24.04 the built-in PHP 8.3 is the simplest choice for both. Use a newer version only after your Composer dependencies support it.
Do I need Supervisor to run Laravel queues?
No. Laravel's docs show Supervisor, but systemd does the same job and is already installed: run queue:work as a service with Restart=always. What you cannot skip is a process manager of some kind, because queue:restart and timeouts make workers exit.
Why do my .env changes have no effect in production?
Because the configuration is cached. After php artisan config:cache or optimize, Laravel stops reading .env and env() returns null outside config files. Run php artisan config:cache again, or deploy, after changing .env.
How much RAM does a Laravel app need on a VPS?
It depends on traffic and workers, so measure. A small app with MariaDB can start on 1 GB, but 2 GB leaves room once you add Redis, queue workers and Vite builds on the server. Each PHP-FPM child and each queue worker holds its own copy of the booted app, so memory grows with pm.max_children and the number of workers.
Can I deploy Laravel on shared hosting instead of a VPS?
Sometimes, if the host lets you point the document root at public and run Composer and cron. Queue workers and zero-downtime releases are usually not possible there. A VPS, or a panel on a VPS, gives you those.
Install ZoPanel free
One command on a fresh Ubuntu or Debian server. Up and running in minutes.
Install in 5 minutesKeep reading

Auto deploy from GitHub to a VPS: webhooks, Actions, zero downtime
Three ways to auto deploy from GitHub to a VPS: GitHub Actions over SSH, webhooks, or a panel with Git deploy, plus release scripts, health checks and rollback.

Deploy ASP.NET Core to a Linux VPS
How to deploy ASP.NET Core to Linux: install .NET 10 on Ubuntu 24.04, publish, run Kestrel under systemd behind nginx with SSL, by hand or with ZoPanel.

Deploy Next.js to a VPS without Vercel
How to deploy Next.js to a VPS: standalone output, systemd, nginx caching for /_next/static, SSL, zero-downtime releases, and what you give up versus Vercel.