Laravel 13 Deployment on a VPS: Nginx, PHP-FPM, and Zero Downtime Step by Step
Your Laravel 13 app works locally, and the moment you put it on a VPS it throws 500s, 502s, or 419s. Classic Nginx + PHP-FPM deployment is not magic: it is a series of verifiable steps from an empty Ubuntu server to HTTPS with zero downtime, and this Laravel VPS deployment guide walks them.
What You Need Before the First Deployment
Laravel 13 Requirements: PHP 8.3 Minimum and a Prepared Server
Most first-deployment failures are version problems: Laravel 13, released in March 2026, sets PHP 8.3 as the minimum version, 2026 guides recommend PHP 8.4 in production, and with PHP 8.2 the deployment fails before it starts. Beyond the interpreter you need Laravel's extensions (mbstring, xml, curl, bcmath, and your database driver, pdo-mysql or pgsql) plus Composer 2 to install dependencies. Work with a sudo user (never root) and a deployment user that owns the files, and update the system before installing anything.
Your git repository must exclude the .env (it holds credentials), vendor/ (installed with Composer on the server), and node_modules (compiled locally or in CI): uploading them by mistake is a security problem and a consistency one.
Installing the Stack: Nginx, PHP-FPM, and the Database
php-fpm with Its Socket and the Database
Nginx does not execute PHP: it hands requests to PHP-FPM, which listens on a Unix socket or a TCP port. Install the php-fpm package for your version, check that the service is active, and write down the socket (for example /run/php/php8.4-fpm.sock): it is the value Nginx will use in fastcgi_pass. Also create a database dedicated to the app and a user with privileges only on that database; avoid the database administrator account in the .env.
Composer Install and the Production .env
With the code on the server, install dependencies with composer install --no-dev --prefer-dist --optimize-autoloader: you skip development packages and generate a fast autoloader; Vite assets are compiled locally or in CI. Then configure the .env with APP_ENV=production, APP_DEBUG=false, and the real credentials: with true, any error exposes stack traces with paths and credentials. Generate the key with php artisan key:generate if the file does not already have one.
Permissions You Do Need and Permissions You Don't
storage/ and bootstrap/cache: The Only Writable Directories
Permissions are the most common problem in production, and the quick fix from the forums is almost always the wrong one. Laravel writes to storage/ (logs, sessions, cache, uploaded files) and bootstrap/cache: give write permissions on those two directories to the web server user or the deployment user, and nothing else. chmod -R 777 fixes the symptom and lets any process modify your code; correct permissions go to the right user, on the directories that need them. And if the worker runs as another user, the files it creates must be readable by the rest of the processes.
Configuring Nginx for Laravel
Document Root public/, try_files, and fastcgi_pass
Misconfigured, the server block gives you 404s on working routes or PHP being downloaded instead of executed. The document root points to public/, never the project root: that way the server only exposes the front controller, and the rest of the code stays out of reach of requests. A minimal server block routes with try_files every request that is not a real file to the front controller:
server {
listen 80;
server_name yourdomain.com;
root /var/www/app/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
}The fastcgi_pass line must point to the same socket as your PHP-FPM pool or you will get a 502. The fastcgi-php.conf include provides the standard variables, including SCRIPT_FILENAME, which tells PHP which file to execute.
Typical Errors: 502, 404s on Routes, and Broken Assets
A 502 means Nginx cannot reach PHP-FPM (a mistyped socket, a stopped service, or permissions); a 404 on routes that exist is usually a wrong document root or a missing try_files. And if assets load broken, check that Nginx serves public/build or public/storage with their location blocks.
Migrations and Caches: Turning the App On
migrate --force and Production Caches
Run php artisan migrate --force, which skips the interactive confirmation: the first deployment creates the tables, and an update applies only the pending ones. Then cache configuration, routes, views, and events (config:cache, route:cache, view:cache, event:cache, or the combined php artisan optimize): that is what separates a slow deployment from a fast one.
The post-deployment 419 almost always comes from the configuration cache: you cached with a key or session different from production, or you changed the .env after caching. Regenerate the correct .env and run config:cache again. Before calling it done, php artisan about shows the environment, PHP, and the drivers in a table.
Queues and Scheduled Tasks in Production
Worker as a systemd Service and queue:restart
If you process jobs, you need two processes that do not exist locally: the worker and the scheduler. The worker runs as a persistent process, not inside a request: a systemd service is the direct path on Ubuntu (define php artisan queue:work --sleep=3 --tries=3 with the project user and automatic restart); Supervisor is the classic alternative. Because it keeps code in memory, run php artisan queue:restart after every deployment so workers restart with the new code; it goes in your script after the symlink flip.
The Scheduler in crontab and the Queue Driver
Laravel does not need one cron entry per task: you register them all in app/Console/Kernel and a single crontab line runs the scheduler every minute: * * * * * php artisan schedule:run. That line points to the project path and runs as the user with access to storage/. The production queue driver is database (you just create the jobs table) or Redis if it is already in your stack; the sync driver runs jobs in the same process and should not be used in production.
HTTPS with Let's Encrypt
Certbot and Proxy Headers
In 2026 there is no excuse to skip HTTPS: Let's Encrypt certificates are free and their renewal is automated. Install Certbot, issue the certificate, and let it configure Nginx; renewal is scheduled as a system timer, and an expired certificate turns your site into a browser error page. Then configure TrustProxies to trust Nginx as a proxy: if Nginx terminates TLS and passes the request over HTTP, without that Laravel generates http URLs; with TrustProxies they use HTTPS.
Zero-Downtime Deployment: The Release Pattern
releases/ + current and Shared Files
Updating the app without cutting service has a standard solution on a single VPS: the release pattern with a symlink, with no Kubernetes or extra orchestration. Every deployment is built completely in a new folder (releases/2026-09-05-1015) and the server serves current, a symlink to the active release: switching the symlink is atomic, so no request ever sees a half-deployed state. The .env and the contents of storage/ are not duplicated in every release: they live in a shared folder, and the new release creates symlinks to them.
Deploy Script, Rollback, and Additive Migrations
Repeatable deployment is a script: prepare the code for the new release, run composer install with production flags, copy the shared .env, create the storage symlinks, run additive migrations, cache config/routes/views, restart the worker with queue:restart, and finally flip the symlink. The order protects the old release if something fails before the flip. And if the new release fails, rolling back is pointing the symlink at the previous one and restarting what needs restarting: keep the last five releases and the rollback takes seconds.
Additive migrations deserve their own rule: until the flip, the old release keeps serving requests, so they must add columns or tables without dropping or renaming what the old code uses; a destructive one before the flip breaks the previous release.
Post-Deploy Checklist and Troubleshooting
Checking Logs, Queues, and the Scheduler, and the Five Common Traps
Review the Laravel logs in storage/logs and the Nginx logs in /var/log/nginx, check that the worker is active, and confirm the scheduler is in crontab: a deployment is not finished until logs, queues, and the scheduler confirm it. The recurring first-deployment failures are five: PHP below 8.3, the 502 from a misconfigured PHP-FPM socket, the 419 from a configuration cache with a wrong .env, too many permissions (chmod 777) or too few on storage and bootstrap/cache, and forgetting to restart the worker after updating code. If your deployment fails, review that list before touching anything else.
Conclusion
Deploying Laravel 13 on a VPS with Nginx and PHP-FPM is a process of verifiable steps: prepare the server with PHP 8.4, upload only the code, point the server block at public/, cache the configuration, run queues and the scheduler as services, and adopt the release pattern to update without cutting service. It is more initial work than a managed platform, but it leaves you in control of the server.