Skip to content
Cloudj

VPS use cases

Deploy Laravel to a VPS: Nginx, PHP-FPM and queues step by step

Deploy Laravel to a VPS step by step: install PHP-FPM and Nginx, configure .env, run queues with Supervisor, schedule tasks and ship releases without downtime.

Updated · 4 min read

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:

  1. A fresh folder per release, so the live site is untouched until the switch.
  2. Shared .env and storage, so uploads, logs and sessions survive releases.
  3. 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).
  4. ln -sfn swaps the symlink in one step. Reloading PHP-FPM clears OPcache's view of old paths.
  5. queue:restart tells workers to finish their current job and restart on the new code.
  6. The last line keeps five releases. To roll back, point current at 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 .env value or a cached config. Run php artisan config:clear and cache again.
  • Permission denied on storage: the PHP-FPM user (www-data) needs write access to shared/storage. Put deploy and www-data in the same group and set group write.
  • Old code still served: Nginx is using $document_root instead of $realpath_root, or PHP-FPM was not reloaded.
  • Jobs run old code: queue:restart was 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.

Deploy a server or order proxies

Pick a location in the console, see the live price, and pay from your balance.