Shared hosting wants to be your whole life: a cPanel panel, a support ticket before you can touch php.ini, and a shared worker pool that tanks the neighbor’s cryptominer. A VPS gives you the PHP version you choose, a database that isn’t on the same box as three strangers, and the scheduler and queue workers that Laravel apps genuinely need. That’s the deal: you trade one click for control.
Deploying Laravel on a VPS is more moving parts than static hosting, but each one is small and the whole path is mechanical once you’ve done it. Here it is, with the two mistakes that actually bite people first. (If you went straight to Laravel Cloud or Forge, this is the self-hosted map you’d want when the managed bill gets loud.)
Some links on this page are affiliate links. They don’t change what we recommend - see our affiliate disclosure.
What you’re building
A standard production layout: PHP-FPM runs your Laravel application, Nginx terminates TLS and hands matching requests to the PHP socket, and one long-lived queue worker plus a single cron line do the async work. One server, one app, no orchestration.
Internet → Nginx (:443, TLS) → PHP-FPM (unix socket) → MySQL / PostgreSQL
│
├─ queue worker (systemd)
└─ scheduler (one cron entry)
| Layer | What it does | Key setting on this page |
|---|---|---|
| PHP-FPM | Runs the app, one request at a time, pooled | listen.own to www-data, listen.owner = www-data |
| Composer | Installs and autoloads dependencies | composer install --no-dev --optimize-autoloader |
| Nginx | Front door: TLS + routes to public/index.php |
try_files + fastcgi_pass unix:...php-fpm.sock |
| Certbot | Issues and renews free Let’s Encrypt certs | sudo certbot --nginx -d app.example.com |
| Database | MySQL or PostgreSQL, your choice | DB_CONNECTION in .env |
| Systemd | Keeps the queue worker alive across reboots | WantedBy=multi-user.target |
| Cron | Runs the Laravel scheduler every minute | * * * * * php artisan schedule:run |
1. Get a server
Grab an Ubuntu LTS VPS - 26.04 “Resolute Raccoon” is the current LTS (released 23 April 2026), with 24.04 still supported.[1] Pin an LTS rather than the newest interim release. If you’re still deciding whether a dedicated box is the right call, the VPS vs shared hosting comparison covers when a shared plan is enough and when it will hurt.
- 4 GB RAM is a comfortable floor for Laravel + PHP-FPM + MySQL on one box; 2 GB works if you split the database out or accept swap during
composer install. - Pick any provider (DigitalOcean, Linode, Vultr, Hetzner). Our three-way comparison of the $6 tier covers the differences that actually change the pick.
2. Lock it down first
Before touching PHP, secure the box. This is the step people skip and regret at 3am.
# as root, create a deploy user and give it sudo
adduser deploy
usermod -aG sudo deploy
# key-based login only
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
# firewall: SSH, HTTP, HTTPS only
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
Then in /etc/ssh/sshd_config set PermitRootLogin no and PasswordAuthentication no, restart SSH (sudo systemctl restart ssh), and keep your current session open until you’ve verified the new login works.
3. Install PHP and Composer
Laravel 13 requires PHP 8.3 or newer, and supports PHP 8.3 through 8.5.[2] On Ubuntu 26.04 the distro ships PHP 8.5; pin a supported line that your packages agree on. The latest stable releases land on php.net, and PHP 8.6 is in pre-release testing as of early November 2026.[3]
sudo apt update
sudo apt install -y nginx php-fpm php-cli php-mysql php-pgsql \
php-mbstring php-xml php-curl php-zip php-bcmath php-gd php-intl
The install pulls the extensions Laravel needs: ctype, cURL, DOM, fileinfo, filter, hash, mbstring, OpenSSL, PCRE, PDO, session, tokenizer and XML.[2] If you use a specific socket path later, note the exact PHP-FPM version that got installed - Ubuntu names it php8.5-fpm and the socket follows.
Install Composer globally:
sudo php -r "copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');"
sudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
Composer 2.11 is current as of 30 October 2026.[4]
4. Get your code and set up .env
Clone the app into its own directory and give it to deploy:
sudo mkdir -p /srv
sudo chown deploy:deploy /srv
git clone https://github.com/you/myapp.git /srv/myapp
cd /srv/myapp
Copy your environment file and fill in production values. The stock .env from composer create-project is for local development, so its APP_DEBUG=true and APP_ENV=local must be flipped:
cp .env.example .env
APP_NAME="My App"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.example.com
DB_CONNECTION=pgsql # or mysql
DB_HOST=127.0.0.1
DB_PORT=5432 # 3306 for mysql
DB_DATABASE=myapp
DB_USERNAME=myapp
DB_PASSWORD=CHANGE_ME
APP_DEBUG must be false in production - the docs are blunt that leaving it true risks exposing sensitive configuration values to end users.[2]
5. Install dependencies and migrate
composer install --no-dev --optimize-autoloader
php artisan migrate --force
--no-dev skips dev-only packages, --optimize-autoloader flattens the autoloader for production speed. --force runs migrations without a confirmation prompt, which matters in a CI build or scripted deploy.
Set directory permissions so the web server can write where Laravel needs to:
sudo chown -R www-data:www-data /srv/myapp/storage /srv/myapp/bootstrap/cache
6. Configure Nginx for PHP-FPM
Create /etc/nginx/sites-available/myapp. This is essentially the server block Laravel documents, and the one rule to respect is that every request must reach public/index.php - never serve from the project root, which would expose .env and other sensitive files to the public internet.[2] For the reasoning behind each proxy directive, see our full Nginx reverse proxy guide.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
root /srv/myapp/public;
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.5-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
fastcgi_hide_header X-Powered-By;
}
location ~ \.php$ { return 404; }
location ~ /\.(?!well-known).* { deny all; }
}
Adjust the socket path to the exact PHP-FPM version you installed (php8.5-fpm.sock above). Enable the site and test:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Two details are worth keeping: the location ~ \.php$ { return 404; } guard makes unhandled .php paths 404 instead of leaking execution, and deny all on dotfiles (except .well-known for ACME challenges) keeps configs and logs out of reach.[2]
7. Free SSL with Let’s Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-run
The Nginx plugin rewrites your server block to add TLS and a redirect from :80 - the plugin itself modifies the server config, then sets up automatic renewal.[5]
8. Optimize for production
Laravel gives you one command that caches config, events, routes and views; run it as part of every deploy:
php artisan optimize
One gotcha that trips everyone once: after php artisan config:cache, the .env file is no longer loaded, and every call to the env() helper returns null.[2] Config values should be read via config() in code, and if you change environment variables you must php artisan config:clear (or optimize:clear) and re-cache. The fallback APP_ENV etc. live in the compiled config - this is by design, not a bug.
9. Run the queue worker as a systemd service
Laravel’s default queue connection is the database, and jobs run through a long-lived worker - php artisan queue:work.[6] That process must stay alive across reboots, which is a job for systemd (if you’d rather use a userspace daemon, our PM2 vs systemd comparison is the source-backed way to decide).
Create /etc/systemd/system/queue-worker.service:
[Unit]
Description=Laravel queue worker
After=network.target mysql.service postgresql.service
[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3
Restart=always
RestartSec=3
Environment=QUEUE_CONNECTION=database
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now queue-worker
sudo systemctl status queue-worker
--tries=3 caps retries so a poisoned job doesn’t spin forever and moves it to the failed_jobs table, which you inspect with php artisan queue:failed.[6] After every deploy that ships new job code, the worker must be restarted to pick up the new code - php artisan reload terminates reloadable services, or restart the unit directly.
10. Wire up the scheduler with one cron line
Instead of a crontab entry per task, Laravel’s scheduler runs everything defined in routes/console.php from a single cron entry.[7] Add this with crontab -e for the deploy user:
* * * * * cd /srv/myapp && php artisan schedule:run >> /dev/null 2>&1
The scheduler itself decides what should run and when - every-minute cron invocation is the documented pattern, and Laravel skips anything not due.[7] Check what’s registered with php artisan schedule:list.
11. Pick the database: MySQL or PostgreSQL
Laravel supports both, so this is about your workload. PostgreSQL 18 is the current stable major (minor 18.6 as of early November 2026) with support until 2030.[8] MySQL 9.7 is the current LTS release line (9.7.4, released 20 October 2026), with the Innovation line at 26.10.[9] Either php-mysql or php-pgsql plus the matching connector in .env is enough; we cover the real engineering differences in PostgreSQL vs managed. For a single Laravel app a plain mysql-server or postgresql package on the box is fine - don’t buy managed unless uptime or backups alone justify it.
12. The Docker Compose alternative
If managing PHP-FPM and systemd units by hand feels like too much, Docker Compose collapses the whole stack into one declarative file - app, database, and worker as services. This is the route we compare properly in Docker vs raw VPS; the short version is that Compose trades the “install once, reboot safe” guarantees of systemd for reproducibility at the cost of another moving part on the box.
At a minimum you want an app service built from a PHP-FPM base image with your code, a database service, an nginx service that mounts the app’s public/ and proxies to PHP-FPM, and a worker service running php artisan queue:work. Nginx still terminates TLS - either mount Certbot certs or let Traefik/Caddy on the host do the ACME dance. The queue worker and scheduler are just extra services in the same file, which is the main win: one docker compose up -d brings up the whole app, and the process supervision problem that systemd solves by hand is solved by container restarts.
Troubleshooting the two failures that actually happen
502 Bad Gateway from Nginx. PHP-FPM isn’t reachable. Check the socket exists and the pool is running: sudo systemctl status php8.5-fpm, then confirm the socket path in your Nginx config matches the actual /run/php/*.sock. A version mismatch (php8.3-fpm.sock in the config, php8.5 installed) is the usual cause.
White page after deploying. Nine times out of ten it’s APP_DEBUG=false hiding a fatal plus a stale config:cache. Read /srv/myapp/storage/logs/laravel.log, then re-run php artisan optimize after any .env change. The docs’ config:cache behavior - .env ignored, env() returning null - is the root cause when logs show missing config values instead of a real exception.[2]
The “that’s it” summary
A VPS gives you the exact PHP version, a private database, and the async machinery Laravel is designed for: one php artisan queue:work under systemd, one cron line for the scheduler, and Nginx + PHP-FPM in front. The version-sensitive pieces are the PHP major (Laravel 13 wants 8.3+) and the socket path in your Nginx config. Nail those two, and everything else - .env, certbot, optimize - is mechanical.
Sources
- Ubuntu 26.04 LTS “Resolute Raccoon” - Canonical release announcement (23 Apr 2026). https://ubuntu.com/blog/canonical-releases-ubuntu-26-04-lts-resolute-raccoon - checked 2026-11-08
- Laravel 13.x - Deployment (server requirements, Nginx config, config:cache, APP_DEBUG). https://laravel.com/docs/13.x/deployment - checked 2026-11-08
- PHP - Downloads & supported versions (8.5 stable, 8.6 RC testing). https://www.php.net/downloads - checked 2026-11-08
- Composer - Download (v2.11.1, 2026-10-30). https://getcomposer.org/download/ - checked 2026-11-08
- Certbot - Using
--nginx(plugin modifies server config). https://eff-certbot.readthedocs.io/en/stable/using.html - checked 2026-11-08 - Laravel 13.x - Queues (queue:work, –tries, failed_jobs, reload). https://laravel.com/docs/13.x/queues - checked 2026-11-08
- Laravel 13.x - Task Scheduling (single cron entry, schedule:run). https://laravel.com/docs/13.x/scheduling - checked 2026-11-08
- PostgreSQL - Versioning policy (18.6 current, support to 2030). https://www.postgresql.org/support/versioning/ - checked 2026-11-08
- MySQL - 9.7 Release Notes (9.7.4, 2026-10-20). https://dev.mysql.com/doc/relnotes/mysql/9.7/en/ - checked 2026-11-08