To deploy Laravel to a VPS you need four things working together: Nginx in front, PHP-FPM running your code, a process manager keeping queue workers alive, and a release routine that does not take the site down. Managed platforms hide these parts; on your own server you set them up once and then understand exactly what runs in production. This guide walks through each step on a fresh Ubuntu server, with commands you can copy.
If you have not chosen a location yet, the Asia VPS guide compares the options. Cloudj has no servers in Vietnam; for apps serving Vietnamese users or a regional audience across Southeast Asia, the nearest locations are Singapore (best VPS in Singapore), Hong Kong (best VPS in Hong Kong) and Tokyo (best VPS in Japan).
Before you deploy Laravel to a VPS
- A server. For a typical Laravel app with a database on the same machine, 2 vCPU and 4 GB RAM is a comfortable start. A smaller server is enough for a small app or staging. Deploy from the Cloudj console; the cloud servers page lists every location.
- A domain with an A record pointing to the server IP.
- A Git repository the server can pull from, with a deploy key.
- A secured server. Do the basics from the new VPS security checklist first: SSH keys, a non-root user, and a firewall open on 22, 80 and 443.
The commands below assume Ubuntu 24.04, PHP 8.3 and a user called deploy. Adjust versions to what your app requires.
Step 1: install PHP-FPM, Nginx and the database
sudo apt update
sudo apt install -y nginx mariadb-server redis-server supervisor unzip git \
php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring php8.3-xml \
php8.3-curl php8.3-zip php8.3-bcmath php8.3-intl php8.3-redis
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
Create a database and user:
sudo mysql -e "CREATE DATABASE app; CREATE USER 'app'@'localhost' IDENTIFIED BY 'change-me'; GRANT ALL ON app.* TO 'app'@'localhost';"
Prefer PostgreSQL? The PostgreSQL on a VPS guide covers that setup instead.
Step 2: lay out the directories for zero-downtime releases
Instead of pulling code into one folder and hoping nothing breaks mid-update, keep each release in its own folder and point a current symlink at the live one. Shared files (.env and storage) live outside the releases.
/var/www/app/
releases/20261111-1200/
shared/.env
shared/storage/
current -> releases/20261111-1200
sudo mkdir -p /var/www/app/{releases,shared/storage}
sudo chown -R deploy:www-data /var/www/app
mkdir -p /var/www/app/shared/storage/{app/public,framework/{cache,sessions,views},logs}
Create /var/www/app/shared/.env with production values: APP_ENV=production, APP_DEBUG=false, your APP_URL, database credentials, QUEUE_CONNECTION=redis and CACHE_STORE=redis. Generate APP_KEY once and never change it, or encrypted data and sessions become unreadable.
Step 3: configure Nginx
Point the web root at current/public, never at the project root.
server {
listen 80;
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^/index\.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;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
$realpath_root matters here: it resolves the current symlink, so PHP-FPM sees the new release path after each switch. Enable the site, reload Nginx, then add HTTPS with Certbot as described in the Nginx reverse proxy and SSL guide.
Step 4: the deploy script
Save this as ~/deploy.sh and run it for every release.
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
REL=$APP/releases/$(date +%Y%m%d-%H%M%S)
git clone --depth 1 [email protected]:you/app.git "$REL"
cd "$REL"
ln -s $APP/shared/.env .env
rm -rf storage && ln -s $APP/shared/storage storage
composer install --no-dev --optimize-autoloader --no-interaction
# npm ci && npm run build # if you build assets on the server
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan storage:link
ln -sfn "$REL" $APP/current
sudo systemctl reload php8.3-fpm
php artisan queue:restart
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf
What each part does:
- A fresh folder per release, so the live site is untouched until the switch.
- Shared
.envandstorage, so uploads, logs and sessions survive releases. - Migrations run before the switch. Write migrations that the old code can live with for a moment (add columns first, remove them in a later release).
ln -sfnswaps the symlink in one step. Reloading PHP-FPM clears OPcache's view of old paths.queue:restarttells workers to finish their current job and restart on the new code.- The last line keeps five releases. To roll back, point
currentat the previous folder and reload PHP-FPM.
Allow the deploy user to reload PHP-FPM without a password by adding a narrow sudoers rule for that single command.
Step 5: keep queue workers alive with Supervisor
Queued jobs (emails, notifications, image processing) need a long-running worker. Supervisor restarts it if it crashes and on reboot.
[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=deploy
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=3600
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
Save it as /etc/supervisor/conf.d/app-worker.conf, then run sudo supervisorctl reread && sudo supervisorctl update. Start with one or two workers per vCPU and watch CPU and RAM in the console metrics.
Step 6: the scheduler
Laravel's scheduler needs one cron entry for the deploy user:
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
Troubleshooting the usual errors
- 500 error right after deploy: check
storage/logs/laravel.log; it is usually a missing.envvalue or a cached config. Runphp artisan config:clearand cache again. - Permission denied on storage: the PHP-FPM user (
www-data) needs write access toshared/storage. Putdeployandwww-datain the same group and set group write. - Old code still served: Nginx is using
$document_rootinstead of$realpath_root, or PHP-FPM was not reloaded. - Jobs run old code:
queue:restartwas skipped.
The official Laravel deployment documentation lists further production optimisations.
FAQ
What size VPS does a Laravel app need?
A small app with its database on the same server runs comfortably on 2 vCPU and 4 GB RAM. Add RAM as queues and traffic grow; you can resize CPU and RAM from the console.
Do I need Docker to deploy Laravel?
No. Nginx, PHP-FPM and Supervisor directly on the server is simple and fast. Docker helps when you run several apps with different PHP versions.
How do I roll back a bad release?
Point the current symlink at the previous release folder and reload PHP-FPM. Roll back database changes only if the migration was not backward compatible.
Where should I host a Laravel app for users in Vietnam?
Cloudj has no servers in Vietnam, so a server abroad adds some network distance compared with an in-country host. Singapore suits a regional Southeast Asian audience; Hong Kong and Tokyo are worth testing too. Deploy small hourly servers in two or three locations, measure from your users' ISPs with ping and mtr, and keep the best one.
Ship your first release
Deploy a server in the Cloudj console, run through the steps once, and every later release becomes a single script. Live prices for every location are on the pricing page.