To deploy Laravel on a VPS over SSH, you provision an Ubuntu 24.04 server, install Nginx with PHP-FPM 8.3 and a database, clone your repository into a timestamped release folder, point Nginx at a symlinked current/public directory, then run Composer, migrations, and the Artisan cache commands from your SSH session. That sequence is the whole job.
Most deploy tutorials stop at the moment the homepage loads. That is about halfway. A Laravel app is only deployed when the scheduler fires every minute, the queue worker picks up your new code, TLS renews itself, and the next release does not take the site offline for 40 seconds.
You already know composer install and php artisan migrate. The harder part is layout and ordering, the decisions that make release 40 as calm as release one. This guide walks the full path, from a bare VPS and an SSH key to a repeatable zero downtime deploy script, then queues, cron, HTTPS, firewall rules, and backups. Every command is copy ready, and the last section is honest about when a manual SSH deploy is the wrong tool.
Key Takeaways
What deploying Laravel on a VPS actually involves
A Laravel VPS deployment is four jobs stacked on top of each other, and skipping any one of them produces an app that half works.
Provision and secure the server. A non-root deploy user, key only SSH, and a firewall that exposes ports 22, 80, and 443.
Install the runtime. Nginx, PHP-FPM with the extensions Laravel needs, a database, Redis, Composer, and Node if you build assets on the box.
Put the code on disk in a swappable way. Release directories and a symlink, so a bad deploy is one
ln -sfnaway from being reverted.Wire the background processes. Supervisor for queue workers, cron for the scheduler, certbot for renewals, and a backup job you have actually restored from once.
Point one and point four are where most first deploys go wrong. The code is the easy part. The parts that page you at 3 a.m. are a full disk from unrotated logs, an expired certificate, and a queue worker that died three days ago without anyone noticing.
If you would rather generate the app and its deployment together instead of assembling both by hand, you can try StackMivo now and come back to this guide for the server internals.
What you need before you deploy Laravel on Ubuntu
Get these five things in place before you open an SSH session. Every one of them is faster to sort out now than halfway through a deploy.
A VPS running Ubuntu 24.04 LTS. Start at 2 vCPU and 4 GB of RAM if you plan to run Redis and queue workers on the same box.
A domain with an A record pointing at the server IP. Certbot cannot issue a certificate until DNS resolves.
An ed25519 SSH key pair on your machine. Generate one with
ssh-keygen -t ed25519if you do not have it.A deploy key or read only token for your Git remote, so the server can clone without your personal credentials.
Your production environment values. Database credentials,
APP_KEY, mail driver, and any object storage keys. Never commit these to the repository.
Pick the stack before you touch the server
The standard production stack for a Laravel app is Nginx in front of PHP-FPM, with MySQL 8 or PostgreSQL 16 for data and Redis for cache, sessions, and queues. Laravel 11 and 12 both require PHP 8.2 or higher, so the PHP 8.3 packages in the Ubuntu 24.04 repositories are a clean fit. On Ubuntu 22.04 the default is PHP 8.1, which is too old, so add the ondrej/php package archive first.
Lock down SSH access first
Deploy as a dedicated user, never as root. Create the user, add it to the www-data group, and push your public key up before you disable password logins.
sudo adduser --disabled-password --gecos '' deployer
sudo usermod -aG www-data deployer
# run this from your laptop, not the server
ssh-copy-id -i ~/.ssh/id_ed25519.pub deployer@your-server-ipThen turn off password authentication and root login with a drop-in config file, and reload the daemon.
# /etc/ssh/sshd_config.d/99-hardening.conf
PasswordAuthentication no
PermitRootLogin no
# then
sudo systemctl restart sshKeep your current session open while you test the new login from a second terminal. Locking yourself out of a fresh VPS is a rite of passage worth skipping.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started freeStep by step Laravel VPS deployment on Ubuntu 24.04
These five steps take a bare server to a live Laravel app over HTTPS. Run them as deployer with sudo.
1. Install Nginx, PHP 8.3, MySQL, and Redis
sudo apt update
sudo apt upgrade -y
sudo apt install -y nginx mysql-server redis-server git unzip curl
sudo apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-redis php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-bcmath php8.3-intlThen add Composer globally.
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.php2. Create the database and a scoped user
Run sudo mysql_secure_installation, then create a database and a user with rights to that database only.
CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app'@'localhost' IDENTIFIED BY 'use-a-generated-password';
GRANT ALL PRIVILEGES ON app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;3. Lay out the release directory structure
This layout is what makes a Laravel SSH deploy reversible. The shared folder holds the two things that must survive every release, your environment file and your storage directory.
/var/www/app
current -> releases/20260824120500
releases/
shared/.env
shared/storagesudo mkdir -p /var/www/app/releases
sudo mkdir -p /var/www/app/shared/storage
sudo chown -R deployer:www-data /var/www/app
sudo chmod -R 775 /var/www/app/shared/storage4. Configure the Nginx server block
Point the root at current/public, not at a release path. The $realpath_root parameters matter more than they look, because they resolve the symlink on every request so PHP never caches a stale release path.
server {
listen 80;
server_name example.com www.example.com;
root /var/www/app/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}Save it to /etc/nginx/sites-available/app, symlink it into sites-enabled, remove the default site, then run sudo nginx -t and sudo systemctl reload nginx.
5. Add HTTPS with Let's Encrypt
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.comThe Certbot package installs a systemd timer that renews certificates automatically, so this is genuinely a set and forget step. Verify it with sudo certbot renew --dry-run.
How to deploy Laravel over SSH with zero downtime
One freelance Laravel developer deployed by rsyncing straight into the live web root. It worked fine for months. Then a release pulled in two new Composer packages, and for the 90 seconds that composer install ran, every request hit a half updated vendor directory. The client's checkout returned server errors in the middle of a promo email send. The fix was not a bigger server. It was building each release in its own directory and flipping a symlink at the end.
Here is that idea as a script. Save it on the server as deploy.sh, or call it from your laptop over SSH.
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/app
RELEASE="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 [email protected]:acme/app.git "$RELEASE"
cd "$RELEASE"
ln -s "$APP_DIR/shared/.env" .env
rm -rf storage
ln -s "$APP_DIR/shared/storage" storage
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan storage:link
php artisan optimize
ln -sfn "$RELEASE" "$APP_DIR/current"
sudo systemctl reload php8.3-fpm
php artisan queue:restartFour details in that script earn their place:
php artisan optimizecaches config, routes, views, and events in one command on Laravel 11 and later. Skip it and every request recompiles the container.migrate --forceruns before the symlink flip, so write migrations that are backward compatible with the release still being served. Add a column now, drop the old one in the next deploy.Reloading PHP-FPM clears opcache. Give the deploy user one narrow sudoers entry rather than blanket sudo, for example
deployer ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm.Rollback is one command. Point
currentat the previous release directory and reload PHP-FPM.
Prune old releases on a schedule so the disk does not fill up. Keeping the last five is a sensible default.
Wire up queues, the scheduler, and caches after the first deploy
An agency team once spent an afternoon chasing a bug that only appeared in production. A new job class had shipped, the web tier served the new code, and the queue kept throwing a class not found error. Their worker had been running since the previous release, holding the old autoload map in memory. One php artisan queue:restart at the end of the deploy script ended the hunt, and it has been in every script they write since.
Keep queue workers alive with Supervisor
A worker started by hand dies with your SSH session. Supervisor restarts it on crash and on reboot, which is why the Laravel queue documentation recommends it.
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
user=deployer
autostart=true
autorestart=true
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/app-worker.log
stopwaitsecs=3600sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start app-worker:*Give the scheduler its one cron entry
Laravel's scheduler needs exactly one crontab line, running as the deploy user. Everything else lives in your PHP schedule definition.
* * * * * php /var/www/app/current/artisan schedule:runEdit it with sudo crontab -u deployer -e. Because the path goes through current, every future release is picked up with no cron change. If you want the output kept, pipe it into a log file that logrotate already manages. Reaching for Artisan commands you half remember is normal at this point, and a php artisan make:model cheatsheet saves a trip to the docs.
Check the permissions that break uploads
PHP-FPM runs as www-data, so that group needs write access to storage and bootstrap/cache. Set 775 on both, keep ownership as deployer:www-data, and set APP_DEBUG=false in the shared .env before you send anyone the link.
Harden and monitor your Laravel VPS deployment
A server that is never patched is a server that gets owned. These four habits cover most of the risk on a single box.
Firewall and brute force protection. Allow only SSH and web traffic, and let fail2ban handle the rest.
Unattended security upgrades. Install the package and let Ubuntu apply security patches on its own schedule.
Backups you have tested. A nightly database dump copied off the server, plus whatever lives in
shared/storage.Uptime and error visibility. An external uptime check, log rotation, and an error tracker so you find out before your users do.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo apt install -y fail2ban unattended-upgradesmysqldump --single-transaction --result-file=/var/backups/app-$(date +%F).sql -u app -p appRun that from cron, then copy the file to object storage the same night. A backup that only exists on the machine it protects is not a backup. The official Laravel deployment documentation is the reference to check whenever a new major version changes the recommended optimisation commands.
When to skip the manual SSH deploy
Doing this by hand once is worth it. You learn where the log files live and what actually breaks. Doing it by hand for every client project is how agencies end up with 14 servers nobody remembers configuring.
Approach | Best when | Trade off |
|---|---|---|
Manual SSH deploy on a VPS | You want full control of the box and the lowest hosting bill | You own every patch, certificate, and disk space alert |
A managed platform such as Laravel Forge, Ploi, or Laravel Cloud | You want provisioning and deploy hooks handled for you | A monthly fee, and less control over how the server is laid out |
Generate the app, then ship it with StackMivo | You are building the application and the pipeline at the same time | You still review the generated Laravel code before it goes live |
That third row is the option most Laravel developers have not tried. StackMivo generates production-ready Laravel apps from a plain English description, including Eloquent models, migrations, controllers, Policies, FormRequests, and Pest tests, because its Laravel-native intelligence is built around framework conventions rather than generic PHP. Its one-click deployment then pushes to Laravel Cloud, Forge, Ploi, or any SSH server, running migrations, clearing caches, restarting queues, and keeping a live build log with instant rollback.
The honest verdict is this. If the server is the product, or you enjoy the operations work, keep the manual pipeline and the script above. If the application is the product and the server is a means to an end, hand the repetitive parts to a tool. Teams building a multi tenant SaaS platform tend to reach that conclusion around the third environment they have to configure by hand. You can get started free and point it at an existing repository to see the difference on your own code.
Deploy Laravel on a VPS and keep the releases boring
A good Laravel VPS deployment is unremarkable. You install Nginx, PHP 8.3, MySQL, and Redis on Ubuntu 24.04. You build each release in its own directory, flip a symlink, reload PHP-FPM, and restart the queue. You let Supervisor watch the workers, cron run the scheduler, and certbot renew the certificate. You back the database up somewhere other than the server it came from.
Start with the script in this guide on a throwaway VPS. Deploy the same commit three times, break it deliberately, and practise the rollback until it feels dull. That hour buys real confidence the first time a release goes wrong at a bad moment.
The skill is worth having whether or not you keep deploying by hand. Once you know exactly what a Laravel SSH deploy does, every automated pipeline you touch afterwards becomes something you can debug instead of something you hope keeps working.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started free