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.

Diagram: nginx serving the current Laravel release on a VPS, with a queue worker and the scheduler beside it

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 install and the cache build happen while the old release keeps serving visitors. If any command fails, set -e stops the script before current changes.
  • php artisan optimize caches config, events, routes and views, as the deployment docs recommend. Once config is cached, .env is not read at runtime, so call env() only inside config/ files.
  • migrate --force runs 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 -T replaces the symlink with a single rename(), so no request ever sees a missing current. ln -sfn alone 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:

  1. One-click installer for a new, empty project: it runs composer create-project laravel/laravel, creates a MariaDB database, writes .env, then runs key:generate, migrate --force and storage:link.
  2. Git deploy for your own repository. ZoPanel detects Laravel from artisan or laravel/framework, picks the PHP version from require.php in composer.json, and proposes composer install --no-dev --optimize-autoloader plus config:cache, route:cache and view:cache. With a build script in package.json, it adds npm ci && npm run build for 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 --force to 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 running queue:work --stop-when-empty --max-time=55 every 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.