# 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.

Source: https://zopanel.net/blog/deploy-laravel-to-vps  
Updated: 2026-10-21

## 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](/blog/lemp-stack-ubuntu) first; this post builds on it.

## Which PHP version does Laravel need?

According to Laravel's [support policy](https://laravel.com/docs/releases#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](/blog/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](https://laravel.com/docs/deployment#server-requirements) 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:

```bash
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:

```bash
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`):

```sql
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:

```bash
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`:

```ini
[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:

```text
/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](https://laravel.com/docs/deployment#nginx), with the root on `current/public` and your own socket. Save it as `/etc/nginx/sites-available/myapp`:

```nginx
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:

```bash
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](/blog/free-ssl-lets-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:

```bash
#!/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](https://laravel.com/docs/deployment#optimization) 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](/blog/git-auto-deploy).

### Step 5: Queue workers under systemd

Laravel's [queue docs](https://laravel.com/docs/queues#queue-workers-and-deployment) 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`:

```ini
[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
```

```bash
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](https://laravel.com/docs/scheduling#running-the-scheduler) that runs every minute. Add it with `sudo crontab -u myapp -e`:

```text
* * * * * 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](/docs/laravel):

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](/docs/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](https://laravel.com/forge/pricing) 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](https://envoyer.io/) ($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](https://deployer.org/) is a free, [MIT-licensed](https://github.com/deployphp/deployer) 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](/pricing) (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.
