Deploying Laravel on Vultr is a Ubuntu server build: PHP FPM behind Nginx, MySQL for data, Composer for dependencies, supervisor for the queue, one cron line for the scheduler, and Let’s Encrypt for TLS. The piece most tutorials skip is the release directory pattern that makes deploys atomic, and it is the piece that turns a nervous manual upload into a command you can run at 4pm on a Friday. This walkthrough covers all of it in order.
/var/www/app/releases/<timestamp>, share storage and .env through symlinks, point /var/www/app/current at the new release, reload PHP FPM, then add supervisor for queue:work and a cron entry for schedule:run.Laravel 13 is the current release and requires PHP 8.3 as a minimum, so a modern Ubuntu LTS with the distribution PHP packages lands you in a supported configuration without extra repositories. If you are still on Laravel 12, PHP 8.2 is the floor and everything below still applies. The official deployment documentation is worth reading alongside this for the optimization commands.
Deploy and secure the instance
In the Vultr control panel choose Products, Compute, Deploy Server. Cloud Compute is the right product family for almost every Laravel app. Vultr’s Regular Performance line starts at $2.50 per month for 1 vCPU and 0.5 GB with an IPv6 only address, and $5.00 per month for a 1 vCPU and 1 GB tier with IPv4. For a real application, start at 2 GB. PHP FPM workers and MySQL both want memory and 1 GB gets tight fast.
ssh root@YOUR_VULTR_IP
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
apt update && apt upgrade -y
apt install -y ufw fail2ban
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshInstall the stack
apt install -y nginx mysql-server supervisor unzip git \
php-fpm php-cli php-mysql php-mbstring php-xml php-curl \
php-zip php-gd php-bcmath php-intl php-redis
php -v
mysql_secure_installation
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
composer --versionTune PHP for a production application. Edit the FPM ini file and the pool config for your PHP version.
# /etc/php/8.3/fpm/php.ini
memory_limit = 256M
upload_max_filesize = 32M
post_max_size = 32M
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
# /etc/php/8.3/fpm/pool.d/www.conf on a 2 GB instance
pm = dynamic
pm.max_children = 12
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
systemctl restart php8.3-fpmopcache.validate_timestamps = 0 makes PHP ignore file changes on disk, which is exactly what you want in production and exactly what will confuse you during a deploy. Your deploy script must reload PHP FPM or the new code will not take effect.A few of those choices are worth explaining. opcache.validate_timestamps = 0 stops PHP from checking file modification times on every request, which is a real throughput win and the reason the deploy script has to reload FPM. The pm.max_children figure is the hard ceiling on concurrent PHP requests, so setting it above what your memory supports converts a traffic spike into a swap storm rather than a queue. And installing php-redis now costs nothing even if you do not enable Redis until later.
Create the database.
mysql -u root -p
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'a_long_random_password';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;Lay out the release directory structure
This is the structure that makes zero downtime deploys possible. Shared state lives in shared/, each deploy gets its own timestamped directory, and current is a symlink that Nginx follows.
mkdir -p /var/www/app/{releases,shared/storage}
chown -R deploy:www-data /var/www/app
# Move the real .env into shared, never into a release
cp /path/to/your/.env /var/www/app/shared/.env
chmod 640 /var/www/app/shared/.env
# Laravel's storage skeleton, created once and shared forever
mkdir -p /var/www/app/shared/storage/{app/public,framework/{cache/data,sessions,views},logs}
chown -R deploy:www-data /var/www/app/shared
chmod -R 775 /var/www/app/shared/storageThe reason storage is shared rather than per release is uploads and logs. If each deploy got a fresh storage directory, every user upload would vanish on the next release. The same logic applies to .env: it holds secrets that should never be in the repository.
The deploy script
Save this as /var/www/app/deploy.sh and run it as the deploy user. Each run creates a release, builds it fully, and only then flips the symlink.
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/app
REPO=git@github.com:you/your-app.git
RELEASE="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main "$REPO" "$RELEASE"
cd "$RELEASE"
ln -nfs "$APP_DIR/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -nfs "$APP_DIR/shared/storage" "$RELEASE/storage"
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan storage:link
# Atomic switch
ln -nfs "$RELEASE" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"
sudo systemctl reload php8.3-fpm
php "$APP_DIR/current/artisan" queue:restart
# Keep the last five releases
cd "$APP_DIR/releases" && ls -1dt */ | tail -n +6 | xargs -r rm -rfThree details carry the weight here. mv -Tf replaces the symlink in a single filesystem operation, so no request ever sees a half updated directory. Reloading PHP FPM clears the opcache, which the earlier validate_timestamps = 0 setting makes mandatory. And queue:restart tells running workers to exit gracefully so supervisor starts them on the new code.
Nginx and TLS
Point the server block at current/public, never at a release path directly, or the symlink flip does nothing. Save this as /etc/nginx/sites-available/app. Certbot will rewrite it in place when you issue the certificate, adding the TLS directives and an HTTP to HTTPS redirect, so there is no need to write those by hand.
server {
listen 80;
server_name example.com www.example.com;
root /var/www/app/current/public;
index index.php;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
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; }
client_max_body_size 32M;
}The $realpath_root lines are not decoration. Without them, PHP FPM caches the resolved symlink target and keeps serving the old release after a deploy, which is a maddening bug to diagnose. Then issue the certificate.
ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
apt install -y certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com
systemctl list-timers | grep certbotQueue worker and scheduler
Laravel needs two background processes and they are configured in different places. Supervisor keeps the queue worker alive and restarts it if it dies. Cron drives the scheduler, which is a separate concern even though both run artisan commands. Skipping either one produces an application that looks healthy in a browser while quietly doing none of its background work.
# /etc/supervisor/conf.d/app-worker.conf
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deploy
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=3600supervisorctl reread
supervisorctl update
supervisorctl status
# Scheduler, as the deploy user
crontab -u deploy -e
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1Sizing and cost reference
| Workload | Instance | pm.max_children | Database |
|---|---|---|---|
| Staging or side project | 1 vCPU, 1 GB | 5 | MySQL on the same box |
| Small production app | 1 vCPU, 2 GB | 12 | Same box, Redis for cache |
| Busy production app | 2 vCPU, 4 GB | 25 | Separate database instance |
| Queue heavy workload | 2 vCPU, 4 GB plus a worker node | 25 | Managed database, Redis queue |
Set pm.max_children by dividing the memory you can spare by the average size of a PHP FPM worker, which you can measure with ps once the app is warm. Guessing high is worse than guessing low, because an overcommitted server starts swapping and everything degrades at once.
If you are running other things on Vultr, the setup here matches our guide to deploying Gatsby on Vultr for the instance and TLS portion. The same Laravel stack underpins installing Bagisto, and if you are comparing PHP frameworks for a VPS, see where to host Phalcon.
Troubleshooting
Deploy finishes but the site serves old code. Either PHP FPM was not reloaded, or the Nginx config is missing the $realpath_root parameters. Both cause the same symptom. Fix the config first, then confirm systemctl reload php8.3-fpm is in the deploy script.
500 error immediately after deploying. Check /var/www/app/shared/storage/logs/laravel.log. The usual causes are a missing .env symlink, a config cache built before the environment file was linked, or a migration that failed. Run php artisan config:clear and rebuild the caches in order.
Jobs sit in the queue and never process. Supervisor is not running the worker, or the worker is running old code. Check supervisorctl status, then confirm your deploy script calls queue:restart. Workers hold code in memory and will happily run a deleted release forever.
“Permission denied” writing to storage or logs. Ownership drift, usually from running an artisan command as root. Reset with chown -R deploy:www-data /var/www/app/shared/storage and chmod -R 775 on the same tree.
Site slows down badly under modest traffic. Check for swap use with free -h. If the server is swapping, pm.max_children is too high for the memory available. Lower it, restart PHP FPM, and consider moving MySQL to its own instance.
Frequently asked questions
What PHP version does Laravel need on Vultr?
Laravel 13 requires PHP 8.3 as a minimum and supports up to 8.5. Laravel 12 requires 8.2. Ubuntu 24.04 LTS ships PHP 8.3 in its default repositories, which covers both, and the widely used ondrej PPA gives you newer builds if you need a specific point release.
How big should the Vultr instance be?
Start at 2 GB of memory for a production application running MySQL on the same box. The 1 GB tier works for staging and small side projects. Memory is almost always the binding constraint before CPU, because PHP FPM workers and the database compete for it.
Do I need Redis?
Not to launch. The file cache and database queue driver work fine at low volume. Add Redis when session or cache writes start showing up in your slow query log, or when you want a queue that dispatches faster than database polling allows.
How do I roll back a bad deploy?
That is what the releases directory is for. Point the symlink at the previous release and reload PHP FPM. Database migrations are the exception, since they do not roll back with the symlink, which is a good reason to keep migrations backward compatible with the previous release.
Should I use Laravel Forge instead?
Forge provisions this exact stack on a Vultr instance and manages the deploy script, certificates and supervisor entries for you. It is a reasonable trade if you value the time. Building it yourself once is still worth doing, because you will need to debug it either way.
The bottom line
A Laravel deployment on Vultr is not complicated, it is just detailed. Nginx pointed at current/public with $realpath_root set, PHP FPM with opcache tuned and reloaded on every deploy, shared .env and storage, supervisor for the queue, and one cron line for the scheduler. Each item exists because leaving it out produces a specific and confusing failure.
Build the release directory pattern on day one rather than retrofitting it. Deploying by pulling into a live directory works right up until a Composer install fails halfway and your site is serving a broken tree. The symlink flip costs you twenty extra lines in a shell script and removes that failure mode entirely, which is the best trade in this whole setup.
