How to Deploy Laravel 11 on Ubuntu 24.04 VPS: Nginx + PHP-FPM + MariaDB
Laravel is the most popular PHP framework in the world, and deploying it to production on your own VPS gives you full control over performance, security, and cost. This guide walks you through deploying a Laravel 11 application on an Ubuntu 24.04 VPS using a production-grade LEMP stack: Nginx as the web server, PHP 8.3 FPM for request handling, MariaDB for the database, and Redis for cache, session, and queue storage. By the end you will have a secure HTTPS site, a running queue worker, a scheduler cron, and a simple deploy script.
New to LEMP? Start with our companion guides: How to Install a LEMP Stack on Ubuntu 24.04, How to Install PHP 8.3 with PHP-FPM, and How to Install MariaDB on Ubuntu 24.04. These cover the baseline we build on here.
Table of Contents
What is Laravel?
Laravel is an open-source PHP web framework created by Taylor Otwell in 2011 and maintained by a large community and a commercial team. It ships with everything a modern web application needs out of the box: an expressive ORM (Eloquent), a routing layer, a templating engine (Blade), built-in authentication, queue abstractions, a task scheduler, a test harness, and a first-class CLI called Artisan. Laravel 11, released in March 2024, slimmed the application skeleton, moved to PHP 8.2 as the minimum version, and introduced per-second rate limiting, health endpoints, and a streamlined config layout.
Laravel powers a huge portion of the modern PHP web: SaaS dashboards, ecommerce storefronts, internal back offices, REST and GraphQL APIs, and full-stack applications built with Livewire or Inertia. Teams choose Laravel because the framework imposes strong conventions (making onboarding fast), the ecosystem is deep (Nova, Horizon, Pulse, Cashier, Sanctum, Passport, Telescope), and the documentation at laravel.com/docs/11.x is genuinely excellent.
On a VPS, a Laravel application typically runs as a bundle of processes: Nginx terminates HTTP and forwards dynamic requests over a Unix socket to PHP-FPM, PHP-FPM spawns worker processes that boot Laravel and handle the request, MariaDB or PostgreSQL stores relational data, Redis holds cache entries, sessions, and queue jobs, a queue worker drains background jobs, and a one-minute cron ticks the Laravel scheduler. This guide assembles all of that cleanly.
Why Deploy Laravel on Your Own VPS?
Deploying Laravel to your own VPS instead of a platform-as-a-service offers concrete advantages:
- Flat, predictable cost -- A VPS with enough headroom for most Laravel apps runs a fraction of the equivalent Heroku or Vapor bill, with no per-request or per-dyno metering.
- Full control of the stack -- Tune PHP-FPM pool sizes, OPcache settings, Nginx buffers, and MariaDB
innodb_buffer_pool_sizeto match your traffic. On PaaS you get whatever the vendor picked. - Co-located database and cache -- Running Nginx, PHP-FPM, MariaDB, and Redis on the same host keeps latency in the microsecond range for DB and cache calls. Most Laravel slowness is N+1 queries hitting a remote database; co-location hides that.
- Persistent disks and cron -- Local storage for uploads, logs, and generated PDFs works out of the box. The scheduler runs from a normal crontab, not a hosted scheduler.
- No cold starts -- Long-running PHP-FPM workers keep Laravel's container warm. Requests return in 20-80 ms typical, versus hundreds of milliseconds on serverless PHP.
- SSH access for debugging --
tail -f storage/logs/laravel.log,php artisan tinker, andhtopall work exactly as you expect.
Cost Comparison: VPS vs. Managed Laravel Hosting
| Scenario | Laravel Forge + DigitalOcean | Laravel Vapor (AWS) | Self-Hosted VPS |
|---|---|---|---|
| Monthly baseline | ~$12 Forge + $12-24 droplet | $39+ Vapor + AWS usage | EUR 7.99-9.99/mo |
| Requests per month | Unlimited on the droplet | Pay per Lambda invocation | Unlimited |
| Database | Self-managed on droplet | RDS extra ($15+) | Self-managed on same VPS |
| Redis / queue | Self-managed on droplet | ElastiCache extra | Self-managed on same VPS |
| SSL certificates | Free (Let's Encrypt) | Included | Free (Let's Encrypt) |
| Typical 5K req/day site | ~$24-36/mo all in | ~$60-120/mo all in | EUR 7.99-9.99/mo flat |
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server
- A domain name with an A record pointing to your server's public IP (for TLS)
- A Git repository containing your Laravel 11 application (GitHub, GitLab, or Bitbucket)
- At least 2 GB of RAM for a small production site (4 GB+ recommended once you add MariaDB and Redis)
Recommended Plan: CloudCore Starter>
For a single production Laravel app with MariaDB and Redis on the same server, we recommend the CloudCore Starter plan:>
- 4 vCPU cores
- 6 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth>
This gives you enough room for PHP-FPM workers, a hot MariaDB buffer pool, Redis, and a queue worker, with headroom for Horizon or a second worker. For higher-traffic applications, step up to CloudCore Professional and enable Octane.
Connect to your server:
ssh root@your-server-ipStep 1: Update System and Install Base Packages
Update the package index and upgrade installed packages so you are working from a current base.
sudo apt update && sudo apt upgrade -yThis guide assumes you already have the LEMP stack installed. If not, follow How to Install a LEMP Stack on Ubuntu 24.04 first, then come back here.
For quick reference, here are the packages Laravel 11 specifically needs on top of a baseline LEMP:
sudo apt install -y \
php8.3-cli php8.3-fpm php8.3-mysql php8.3-pgsql \
php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip \
php8.3-bcmath php8.3-gd php8.3-intl php8.3-redis \
php8.3-opcache \
git unzipVerify PHP:
php -vExpected output:
PHP 8.3.6 (cli) (built: Apr 15 2026 10:00:00) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.6, Copyright (c) Zend Technologies
with Zend OPcache v8.3.6Laravel 11 requires PHP 8.2 or newer -- PHP 8.3 gives you the best performance and the longest support window.
Step 2: Install Composer 2
Composer is PHP's dependency manager. Laravel projects declare their dependencies in composer.json, and Composer resolves and installs them. Install Composer 2 globally:
cd /tmp
curl -sS https://getcomposer.org/installer -o composer-setup.php
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.phpVerify:
composer --versionExpected output:
Composer version 2.7.7 2026-04-15 12:00:00Composer 2 is dramatically faster than Composer 1 and uses significantly less memory during dependency resolution. If you installed from a distribution package and get Composer 1, uninstall it and use the installer above.
Step 3: Clone the Laravel App to /var/www/app
By convention, Laravel applications live under /var/www/ on Ubuntu. Create the directory, make your SSH user the owner temporarily (so Composer does not run as root), and clone the repo.
sudo mkdir -p /var/www
sudo chown -R $USER:$USER /var/www
cd /var/www
git clone https://github.com/your-org/your-laravel-app.git app
cd appIf the repository is private, use an SSH deploy key or a personal access token. For deploy keys, generate a key on the server and add the public half to the repository's deploy keys in GitHub or GitLab:
ssh-keygen -t ed25519 -C "deploy@your-server" -f ~/.ssh/deploy_key -N ""
cat ~/.ssh/deploy_key.pubThen add an entry to ~/.ssh/config:
Host github.com
IdentityFile ~/.ssh/deploy_key
IdentitiesOnly yesStep 4: Install Dependencies and Configure .env
Install PHP dependencies in production mode. --no-dev skips packages only needed for local development (PHPUnit, Faker, debugbar), and --optimize-autoloader generates a classmap for faster autoloading.
cd /var/www/app
composer install --no-dev --optimize-autoloaderExpected output (abbreviated):
Installing dependencies from lock file
Verifying lock file contents can be installed on current platform.
Package operations: 95 installs, 0 updates, 0 removals
- Installing laravel/framework (v11.30.0): Extracting archive
...
Generating optimized autoload files
> @php artisan package:discover --ansi
Discovered Package: laravel/sanctum
Discovered Package: laravel/tinker
Package manifest generated successfully.Copy the example environment file and generate an application key:
cp .env.example .env
php artisan key:generateThe key:generate command writes a 32-byte base64 value to APP_KEY in .env. Laravel uses this key for signing cookies, encrypting session data, and encrypting columns. Do not regenerate it on an existing production database -- any data encrypted with the old key will become unreadable.
Open .env and set the production values:
nano .envKey settings:
APP_NAME="Your App" APP_ENV=production APP_KEY=base64:... APP_DEBUG=false APP_URL=https://yourdomain.comLOG_CHANNEL=daily LOG_LEVEL=info
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=your_app DB_USERNAME=your_app_user DB_PASSWORD=a-strong-password
CACHE_STORE=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_HOST=smtp.your-provider.com MAIL_PORT=587 [email protected] MAIL_PASSWORD=... MAIL_ENCRYPTION=tls MAIL_FROM_ADDRESS="[email protected]" MAIL_FROM_NAME="${APP_NAME}"
Critical production settings:
APP_ENV=production-- Hides verbose errors and enables production optimizationsAPP_DEBUG=false-- Prevents stack traces leaking to end usersAPP_URL=https://...-- Used byroute()and asset helpers; must match your public URL including the scheme
sudo mariadbCREATE DATABASE your_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'your_app_user'@'127.0.0.1' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON your_app.* TO 'your_app_user'@'127.0.0.1';
FLUSH PRIVILEGES;
EXIT;If you prefer PostgreSQL, install postgresql and php8.3-pgsql instead, then set DB_CONNECTION=pgsql and DB_PORT=5432 in .env.
Step 5: Run Migrations and Cache Config
Run database migrations to create your application's schema:
php artisan migrate --forceThe --force flag is required in the production environment -- it tells Artisan you intentionally want to run destructive commands against a live database. Expected output:
INFO Preparing database.Creating migration table ............................... 9.12ms DONE
INFO Running migrations.
2014_10_12_000000_create_users_table ................... 42.08ms DONE 2014_10_12_100000_create_password_reset_tokens_table ... 28.45ms DONE 2019_08_19_000000_create_failed_jobs_table ............. 31.11ms DONE 2019_12_14_000001_create_personal_access_tokens_table .. 35.90ms DONE ...
If your app ships with seeders for initial reference data (plans, countries, roles), run them too:
php artisan db:seed --forceCache Config, Routes, and Views
Laravel parses config/*.php, registers routes, and compiles Blade views on every request by default. In production, pre-build caches so each request skips that work entirely:
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cacheExpected output:
INFO Configuration cached successfully.
INFO Routes cached successfully.
INFO Views cached successfully.
INFO Events cached successfully.These caches live in bootstrap/cache/ and are regenerated every deploy. Important: once you run config:cache, Laravel reads config from the cached file and ignores further changes to .env until you re-run the command or clear the cache with php artisan config:clear. This trips up nearly every first-time Laravel deployer -- if an env change does not seem to take effect, clear and rebuild the config cache.
Step 6: Set File Permissions
Laravel needs to write to two directories at runtime: storage/ (logs, compiled views, file cache, uploads) and bootstrap/cache/ (cached config, routes, services). The Nginx/PHP-FPM processes run as www-data on Ubuntu, so those directories must be writable by that user.
Set ownership and permissions:
sudo chown -R www-data:www-data /var/www/app
sudo find /var/www/app -type f -exec chmod 644 {} \;
sudo find /var/www/app -type d -exec chmod 755 {} \;
sudo chmod -R 775 /var/www/app/storage
sudo chmod -R 775 /var/www/app/bootstrap/cacheIf you want to continue editing files as your SSH user without sudo, add yourself to the www-data group and use group-writable permissions:
sudo usermod -a -G www-data $USER
sudo chown -R $USER:www-data /var/www/app
sudo chmod -R g+w /var/www/app/storage /var/www/app/bootstrap/cacheLog out and back in for the group membership to take effect.
Permission errors after deploy are the single most common Laravel issue. If you see The stream or file "/var/www/app/storage/logs/laravel.log" could not be opened, the fix is almost always to re-run the chown and chmod commands above.
Step 7: Configure Nginx with PHP-FPM
Laravel's public document root is /var/www/app/public -- never serve the project root directly, or you will expose .env, composer.json, and the vendor/ directory to the internet.
If you need the PHP-FPM basics, see How to Install PHP 8.3 with PHP-FPM on Ubuntu 24.04. This guide assumes the default php8.3-fpm pool listening on /run/php/php8.3-fpm.sock.
Create the Nginx server block:
sudo tee /etc/nginx/sites-available/app > /dev/null <<'EOF' server { listen 80; listen [::]:80; server_name yourdomain.com www.yourdomain.com;root /var/www/app/public; index index.php index.html;
charset utf-8; client_max_body_size 32m;
# Security: hide dotfiles and sensitive paths location ~ /\.(?!well-known).* { deny all; }
# Static assets with long cache location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2|svg)$ { expires 1y; access_log off; add_header Cache-Control "public, immutable"; try_files $uri /index.php?$query_string; }
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 ~ \.php$ { fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; fastcgi_hide_header X-Powered-By; fastcgi_read_timeout 120s; } } EOF
The Laravel-specific lines worth understanding:
try_files $uri $uri/ /index.php?$query_string;-- Laravel's front controller pattern. Every request that does not match a real file or directory is rewritten to/index.php, which boots the framework and dispatches to the router.root /var/www/app/public;-- The document root is thepublic/subdirectory, not the project root.fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;-- Resolves symlinks correctly, which matters for zero-downtime deploy schemes that symlinkcurrentto the latest release.
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxVisit http://yourdomain.com in a browser. You should see your Laravel application (or the default Laravel welcome page if the repo is a fresh laravel new install).
Step 8: Issue a TLS Certificate with Certbot
Let's Encrypt provides free, automatically renewed TLS certificates. The Certbot Nginx plugin edits your server block to add HTTPS and a redirect from HTTP.
Install Certbot:
sudo apt install -y certbot python3-certbot-nginxIssue and install the certificate:
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot will ask for an email address (for expiry reminders), agree to the terms, and whether to redirect HTTP to HTTPS -- choose redirect. After success, your server block listens on both :80 (redirects) and :443 (HTTPS with HSTS-ready defaults).
Verify auto-renewal is scheduled:
sudo systemctl status certbot.timerTest a renewal dry run:
sudo certbot renew --dry-runOnce HTTPS is live, double-check that APP_URL in .env starts with https:// and re-run php artisan config:cache. Laravel uses APP_URL when generating absolute URLs for emails, notifications, and some redirects -- if it is set to http:// while the site is served over HTTPS, some links and CSRF token comparisons will misbehave.
Step 9: Wire Up Redis for Cache, Session, and Queue
Redis is dramatically faster than the file or database drivers for Laravel's cache, session, and queue. If you do not have Redis installed yet, see How to Install Redis on Ubuntu 24.04.
Confirm Redis is running and responding:
redis-cli pingExpected output:
PONGIn .env, point Laravel at Redis:
CACHE_STORE=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379
Laravel 11 uses phpredis (the PHP extension) by default when available, which is 2-3x faster than the pure-PHP predis client. The php8.3-redis package installed in Step 1 provides the extension.
Re-cache config so Laravel picks up the new values:
php artisan config:cacheQuick smoke test:
php artisan tinker>>> Cache::put('hello', 'world', 60);
=> true
>>> Cache::get('hello');
=> "world"
>>> exitIf you want to separate cache, session, and queue data into different Redis databases (so flushing one does not affect the others), edit config/database.php and set distinct database numbers for the cache, default, and queue Redis connections -- then reference them in config/cache.php, config/session.php, and config/queue.php.
Step 10: Run the Queue Worker as a systemd Service
Any Laravel app that sends emails, processes uploads, generates PDFs, or calls external APIs should push those jobs onto a queue and let a background worker drain them. php artisan queue:work is the worker loop. In production, wrap it in a systemd unit so it starts on boot and restarts on failure.
Create the service file:
sudo tee /etc/systemd/system/app-worker.service > /dev/null <<'EOF' [Unit] Description=Laravel Queue Worker for App After=network.target redis-server.service mariadb.service[Service] Type=simple User=www-data Group=www-data Restart=always RestartSec=3 WorkingDirectory=/var/www/app ExecStart=/usr/bin/php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000 StandardOutput=append:/var/log/app-worker.log StandardError=append:/var/log/app-worker.log
[Install] WantedBy=multi-user.target EOF
A few flags worth understanding:
--sleep=3-- When the queue is empty, sleep 3 seconds before polling again. Lower values use more CPU; higher values add latency.--tries=3-- Retry failed jobs up to 3 times before writing them to thefailed_jobstable.--max-time=3600-- Gracefully exit after one hour. systemd will restart the worker, ensuring long-lived PHP processes do not accumulate memory leaks.--max-jobs=1000-- Same idea but job-count based. Whichever limit hits first triggers restart.
sudo touch /var/log/app-worker.log
sudo chown www-data:www-data /var/log/app-worker.logEnable and start the worker:
sudo systemctl daemon-reload
sudo systemctl enable --now app-worker
sudo systemctl status app-workerExpected output:
● app-worker.service - Laravel Queue Worker for App
Loaded: loaded (/etc/systemd/system/app-worker.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-04-16 10:00:00 UTC; 5s ago
Main PID: 4567 (php)
Tasks: 1 (limit: 9423)
Memory: 38.2MCritical: after every deploy, restart the worker so it picks up the new code:
sudo systemctl restart app-workerOr, preferred, send the signal from Artisan which uses the worker's built-in graceful restart:
php artisan queue:restartThis causes workers to exit gracefully after their current job, and systemd immediately respawns them with the new codebase.
Horizon Alternative
For apps with multiple queues, priorities, or dozens of worker processes, install Laravel Horizon (composer require laravel/horizon). Horizon replaces queue:work with a process supervisor that auto-balances workers across queues and exposes a dashboard at /horizon with throughput, runtime, and failure metrics. Run php artisan horizon under systemd the same way as queue:work above.
Supervisor Alternative
If you prefer Supervisor over systemd (it was the recommended pattern in the Laravel docs for years), install it with sudo apt install -y supervisor and drop an /etc/supervisor/conf.d/app-worker.conf with numprocs=2 to run two worker processes. systemd is the more modern choice on Ubuntu 24.04, but both work.
Step 11: Register the Scheduler Cron
Laravel's scheduler lets you define recurring tasks in code (app/Console/Kernel.php or the routes/console.php file in Laravel 11) instead of juggling crontab entries. But the scheduler itself needs to be ticked every minute by a system cron.
Edit the www-data crontab:
sudo crontab -u www-data -eAdd one line:
* cd /var/www/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1Save and exit. That's it -- Laravel now evaluates its scheduled tasks every minute. Verify cron received the entry:
sudo crontab -u www-data -lAny $schedule->command(...)->daily(); or $schedule->job(new CleanupJob())->hourly(); definitions in your app will now run. Test the scheduler manually:
sudo -u www-data php /var/www/app/artisan schedule:listThis lists every registered scheduled task with its next run time.
Laravel Octane with FrankenPHP or RoadRunner
Traditional PHP-FPM boots Laravel's service container on every single request. That takes 20-60 ms of pure framework overhead. Laravel Octane keeps the framework resident in long-lived worker processes, answering requests in 2-5 ms -- a 5-10x speedup for request dispatch.
Octane supports three servers:
- FrankenPHP -- The newest and simplest. A single Go binary that bundles PHP, speaks HTTP/2 and HTTP/3, and does automatic HTTPS. Recommended for new Octane deployments.
- RoadRunner -- A mature Go-based application server, battle-tested at scale.
- Swoole -- A PHP extension that provides coroutines and async primitives. Powerful but adds complexity.
cd /var/www/app
composer require laravel/octane
php artisan octane:install --server=frankenphpThe installer downloads FrankenPHP and wires up the required packages. Run Octane behind a systemd unit instead of PHP-FPM:
# Replace Nginx proxying to FPM with Nginx proxying to Octane at 127.0.0.1:8000
Or run FrankenPHP directly on :443 since it handles TLS itself
php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000Octane is not free complexity -- singletons, static state, and global container bindings behave differently because the framework stays in memory across requests. Audit your code for leaks before flipping the switch. For most CRUD apps, PHP-FPM + OPcache is plenty fast and operationally simpler. Reach for Octane when you have profiled your app and the bottleneck is framework boot time, not database queries.
Backups: Database Dump + App Directory
You need two backup streams: the database and the application directory (for user uploads in storage/app/ and the .env file).
Database Backup Script
Create a nightly dump rotated daily:
sudo tee /usr/local/bin/backup-app-db.sh > /dev/null <<'EOF'
#!/bin/bash
set -euo pipefailBACKUP_DIR=/var/backups/app-db
DB_NAME=your_app
DB_USER=your_app_user
DB_PASS=a-strong-password
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
mysqldump --single-transaction --quick --lock-tables=false \
-u"$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip > "$BACKUP_DIR/$DB_NAME-$STAMP.sql.gz"
Keep the last 14 dumps
find "$BACKUP_DIR" -name "$DB_NAME-*.sql.gz" -mtime +14 -delete
EOF
sudo chmod +x /usr/local/bin/backup-app-db.shSchedule it:
sudo crontab -e15 3 * /usr/local/bin/backup-app-db.shApp Directory Backup
The code itself lives in Git, so the only things that need backing up are uploaded files in storage/app/ and the .env file. A weekly tarball is usually enough:
sudo tar -czf /var/backups/app-storage-$(date +%Y%m%d).tar.gz \
-C /var/www/app storage/app .envFor serious setups, ship both the database dumps and the storage archives off-server with restic or rclone to S3, Backblaze B2, or another VPS.
A Simple Deploy Script
For a small team, a single bash script wrapped in SSH is enough to deploy reliably without needing Forge, Envoy, or a full CI/CD system.
Drop this at /var/www/app/deploy.sh:
sudo tee /var/www/app/deploy.sh > /dev/null <<'EOF' #!/bin/bash set -euo pipefailcd /var/www/app
echo "==> Pulling latest code" git pull --ff-only origin main
echo "==> Installing dependencies" composer install --no-dev --optimize-autoloader --no-interaction
echo "==> Running migrations" php artisan migrate --force
echo "==> Rebuilding caches" php artisan config:cache php artisan route:cache php artisan view:cache php artisan event:cache
echo "==> Restarting queue workers" php artisan queue:restart
echo "==> Deploy complete: $(git rev-parse --short HEAD)" EOF sudo chmod +x /var/www/app/deploy.sh sudo chown www-data:www-data /var/www/app/deploy.sh
Trigger it from your laptop:
ssh deploy@your-server "sudo -u www-data /var/www/app/deploy.sh"For true zero-downtime deploys (where the old code keeps serving requests until the new code is fully built), graduate to a release-directory pattern: clone into /var/www/app/releases/<timestamp>/, run build steps there, then atomically symlink /var/www/app/current to the new release. Deployer automates exactly this pattern for Laravel.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
500 Server Error with no details in the browser | APP_DEBUG=false hides errors from users (correct) | Read the real error in storage/logs/laravel.log and the web server log at /var/log/nginx/error.log |
The stream or file ".../laravel.log" could not be opened: Permission denied | www-data cannot write to storage/ | Re-run sudo chown -R www-data:www-data /var/www/app && sudo chmod -R 775 /var/www/app/storage /var/www/app/bootstrap/cache |
| Queue jobs not running after deploy | Worker is still running old code from before the deploy | Run php artisan queue:restart -- workers exit gracefully and systemd respawns them with the new code |
.env changes have no effect | Config is cached in bootstrap/cache/config.php | Run php artisan config:clear to read from .env again, or php artisan config:cache to rebuild with new values |
Class "Redis" not found | php8.3-redis extension missing | sudo apt install -y php8.3-redis && sudo systemctl restart php8.3-fpm |
SQLSTATE[HY000] [2002] Connection refused | DB credentials wrong or MariaDB not running | Check DB_HOST, DB_PORT, credentials in .env. Verify with mariadb -u your_app_user -p your_app. Remember to config:cache after fixing. |
419 Page Expired on form submit | CSRF token mismatch -- often caused by session store misconfiguration or mismatched APP_URL | Verify SESSION_DRIVER=redis and APP_URL matches the browser URL exactly including https://. Clear browser cookies for the domain. |
| OPcache serving stale code after deploy | OPcache cached the old file contents | Add sudo systemctl reload php8.3-fpm to your deploy script, or enable opcache.validate_timestamps=1 with revalidate_freq=0 during the deploy window |
| Huge file upload fails with 413 | Nginx client_max_body_size is smaller than the upload | Raise it in the server block (client_max_body_size 64m;) and in php.ini (upload_max_filesize and post_max_size), then reload both services |
| Scheduler not running scheduled jobs | Cron entry missing, wrong user, or schedule:list shows nothing registered | Run sudo crontab -u www-data -l to confirm the entry. Run php artisan schedule:list as www-data to confirm registration. |
Reading Laravel Logs
tail -f /var/www/app/storage/logs/laravel.logWith LOG_CHANNEL=daily, Laravel rotates logs as laravel-YYYY-MM-DD.log. Keep an eye on size -- a chatty app can fill a small disk in days.
FAQ
Should I use MySQL, MariaDB, or PostgreSQL for Laravel?
All three are first-class citizens in Laravel. MariaDB is the drop-in replacement for MySQL that ships in Ubuntu repositories and is what this guide uses -- it is fully compatible with Laravel's mysql driver. MySQL (the Oracle upstream) works identically if you prefer it. PostgreSQL offers stronger JSON support, better full-text search, and more mature transactional DDL, which matters if you run complex migrations in production. For most Laravel apps the choice is not decisive -- pick whichever your team is more comfortable operating.
Do I really need Redis, or is the database cache driver fine?
The database cache driver works for small apps and avoids adding a service. But sessions in particular hit the cache on every single authenticated request, and using MariaDB for that creates unnecessary write load and lock contention. Redis handles session and cache traffic in-memory at sub-millisecond latency and costs almost nothing in RAM (a small app uses 20-50 MB). For any production Laravel deployment, installing Redis is worth the 5 minutes.
How do I handle zero-downtime deploys?
The script in this guide has a brief window (a few hundred milliseconds) where migrations run before caches rebuild, during which requests can hit an inconsistent state. For true zero-downtime, use a release-directory pattern: each deploy creates releases/YYYYMMDDHHMMSS/, builds there, and atomically symlinks current to the new directory. Laravel Envoyer and Deployer automate this. Alternatively, design migrations to be backward compatible (add columns before removing them, keep old and new code paths running for one release) so a rolling restart never serves broken responses.
Can I run multiple Laravel apps on the same VPS?
Yes. Create /var/www/app1, /var/www/app2, etc., each with its own Nginx server block bound to a different domain. PHP-FPM can run separate pools per app (so one noisy app cannot starve another of workers) -- copy /etc/php/8.3/fpm/pool.d/www.conf to a new file, rename the [www] section header to [app2], change the socket path, and restart PHP-FPM. Each app gets its own database, Redis DB number, and queue worker systemd unit. A 6 GB VPS comfortably runs 3-5 small Laravel apps side by side.
What PHP-FPM pool size should I use?
PHP-FPM's pm.max_children caps how many concurrent requests can be served. A reasonable starting point: divide your available RAM (minus OS, MariaDB buffer pool, and Redis) by the RSS of one PHP-FPM worker (typically 40-80 MB for a Laravel app). On a 6 GB server with 1 GB for MariaDB and 200 MB for Redis, that leaves ~4.5 GB for PHP-FPM, yielding around 50-80 workers at 60 MB each. Start with pm.max_children=30, monitor with sudo tail -f /var/log/php8.3-fpm.log for server reached pm.max_children warnings, and raise as needed. More detail in our PHP-FPM tuning guide.
Next Steps
Your Laravel 11 application is live on your VPS with Nginx, PHP-FPM, MariaDB, Redis, a queue worker, TLS, and a deploy script. Here is what to tackle next:
- Add monitoring -- Install Laravel Pulse for an in-app dashboard of slow queries, slow jobs, and slow requests. Pair with Uptime Kuma for external HTTP and SSL expiry checks.
- Set up error tracking -- Pipe exceptions to Sentry or Bugsnag so you hear about production errors before your users do. Both ship with first-class Laravel packages.
- Harden the server -- Disable SSH password auth, configure UFW to allow only 22/80/443, install Fail2ban to throttle brute-force attempts, and enable automatic security updates with
unattended-upgrades.
- Graduate to Horizon -- If your app dispatches jobs frequently, replace the single
queue:workworker with Laravel Horizon for a supervised, observable, auto-balancing queue system.
- Read the official deployment docs -- Laravel's deployment guide at laravel.com/docs/11.x/deployment is short and covers the same checklist we used here. Bookmark it.
Deploy Laravel on a Tuned VPS in Minutes>
Our CloudCore Starter plan is sized exactly for a production Laravel app: 4 vCPU, 6 GB RAM, 100 GB NVMe SSD, full root access, Ubuntu 24.04 LTS.>
- Provision in under 60 seconds
- Free unlimited bandwidth
- Hourly snapshots available
- 99.9% uptime SLA>
Launch your CloudCore Starter VPS and have Laravel in production by the end of your coffee.