When you deploy Laravel on a VPS, the job is done right only when three things stay alive without you: the queue worker, the scheduler, and the site itself after every git pull. This guide builds exactly that on Ubuntu 22.04, the newest Ubuntu image on our Iran cloud servers: PHP 8.3 from the ondrej PPA, Nginx, MariaDB, Composer, a systemd unit for the queue, a single cron line, free SSL and a deploy script of under 20 lines. Whether Composer can reach Packagist from a datacenter in Iran is answered with dated measurements, not guesswork.
Laravel on shared hosting vs a VPS: four things shared hosting can't give Laravel
Laravel will "run" on shared hosting, but it lacks four things a real project needs. First, the queue worker: php artisan queue:work is an always-on process, and shared hosting kills persistent background processes. Second, the scheduler: it needs a once-a-minute cron job that calls schedule:run. Third, Composer and SSH on the server itself. Fourth, the PHP version: Laravel 13 won't install on anything below PHP 8.3, and on shared hosting the PHP version is your provider's decision, not yours.
The general debate is covered in our shared hosting vs VPS comparison. For Laravel specifically, a simple site with no jobs may get by, but the moment you make your first dispatch() call you need a server with root access. Mehran Host does not sell "Laravel hosting" as a shared plan.
Hourly billing removes the fear of paying for a month of server time just to run an experiment. Create a 2 GB server, work on it for two hours and, if it doesn't work out, delete it: you are billed for those two hours. One thing to know before you press Create server: your wallet balance must be at least the larger of two amounts, the minimum top-up or 24 hours of that server's rate; otherwise the panel sends you back to the wallet page. Whatever is left after you delete the server stays in your wallet as credit.
Laravel server requirements: how much RAM, CPU and disk does a deployment need?
Mehran Host's server builder has fixed ranges: 1 to 10 GB of RAM, 1 to 8 cores and 10 to 100 GB of NVMe disk in 5 GB steps, with a default limit of 5 servers per account. The disk column below is there on purpose: the panel filters ready-made apps by their minimum resources, and Coolify doesn't even appear in the list on a 10 GB disk:
| Scenario | RAM | Cores | Disk | Notes |
|---|---|---|---|---|
| The two-hour test in this guide | 1 GB | 1 | 10 GB | Enough, provided you add swap; composer update runs out of memory on 1 GB |
| A typical site: Nginx + PHP-FPM + MariaDB on one server | 2 GB | 2 | 20 GB | The default choice in this article |
| With Redis, several queue workers or high traffic | 4 GB | 2 to 4 | 30 GB | Horizon and several concurrent queue:work processes are comfortable here |
| The Coolify shortcut (next section) | 4 GB (minimum 2) | 2 | 30 GB | Mehran Host's floor for Coolify is 30 GB of disk; Coolify's own official minimum is 10 GB |
Choose the "Ubuntu 22.04" image; it is the newest Ubuntu on the Iran node, and if you also see "Ubuntu 22.04.1" in the list, it is the same jammy release. The paths and package names in this article are specific to 22.04, whose standard support runs until April 2027. Provisioning takes under 60 seconds, and the root password and the browser-based console are on the server's page.
Start small: upgrades are done from the server page without reinstalling, upward only and with 24 hours of the new rate in your wallet, and growing the disk requires a restart. As the hourly cloud server guide explains, a powered-off server is still billed at 50% of the hourly rate, and only deleting the server stops the charges. The price of any combination is in the cloud server price calculator, the VPS for developers page sums up root access, the console and the hourly model, and for users outside Iran the same panel creates a server outside Iran.
On a 1 GB server, create swap first; without it, composer update dies with an out-of-memory error:
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab
On a production server, only ever run composer install against a committed composer.lock, never composer update.
The shortcut: when does a one-click Coolify or CloudPanel make sense for Laravel?
For a "git push and you're done" workflow you don't have to leave this panel: when you create a server, you can tick one ready-made app, free of charge and exactly one per build. Coolify is an open-source deployment platform that installs Docker itself, deploys on every push and obtains SSL certificates; its official minimum is 2 cores, 2 GB of RAM and 10 GB of disk, but Mehran Host's panel only offers it from 2 GB of RAM and 30 GB of disk upward. CloudPanel is lighter and runs PHP sites on Nginx from a web interface; its official minimum is 2 GB of RAM, and our panel leaves it available from 1 GB of RAM and 15 GB of disk, which is below that official minimum. How it compares with the alternatives is covered in free control panels.
If the automatic installation doesn't complete, the server page gives you a one-line command to paste into the browser-based console. The manual route is the better choice when you have 1 or 2 GB of RAM and don't want to hand part of it to Docker.
Install PHP 8.3 on Ubuntu 22.04 and prepare Nginx for Laravel
Ubuntu 22.04 ships PHP 8.1 in its own repository, and Laravel 13 won't install on anything below 8.3: composer install stops with "your php version (8.1.x) does not satisfy that requirement". So PHP has to come from the ondrej PPA. Option A, as root:
apt update && apt install -y software-properties-common
add-apt-repository -y ppa:ondrej/php
apt update
apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring \
php8.3-xml php8.3-zip php8.3-intl php8.3-bcmath nginx mariadb-server git unzip
php -v
PHP 8.3.33 (cli) (built: Jul 30 2026 10:41:07) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.33, Copyright (c) Zend Technologies
with Zend OPcache v8.3.33, Copyright (c), by Zend Technologies
The number after 8.3 changes with every release. These packages cover Laravel 13's required extensions (Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer, XML), plus intl and bcmath for common packages, and zip and unzip for Composer.
Option B, with no commands at all: tick the "PHP 8.3" app when you create the server. On Ubuntu, this one-click app adds the same ppa:ondrej/php, installs the same ten packages and enables php8.3-fpm, but it doesn't install a web server or a database, and php8.3-pgsql isn't in its list. Once the server is delivered, all that's left is apt install -y nginx mariadb-server git unzip.
phpinfo() page at /var/www/html/info.php. If you built your server with it, add the ondrej PPA and install the php8.3-* packages, point fastcgi_pass at /run/php/php8.3-fpm.sock and delete the info page: rm -f /var/www/html/info.php /etc/nginx/conf.d/default-php.conf && nginx -t && systemctl reload nginx. The database root password is in the installation report and in /root/.my.cnf.A word on versions: PHP 8.3 is Laravel 13's minimum, but since the start of 2026 it only receives security fixes, and only until December 31, 2027. PHP 8.4 and 8.5 are in active support, and if your packages allow it, simply change 8.3 in the commands. According to its own page, the ondrej repository is being merged into packages.sury.org; if apt update ever warns you about it, follow the migration guide on that page. General Nginx and firewall settings are covered in setting up Nginx on Ubuntu.
Getting your Laravel project onto the server: Composer and Git
The four lines that install Composer are on the Composer download page, and the second of them checks the installer's SHA-384 hash. That hash changes with every release, so copy it from the page itself, not from an article. Then:
mv composer.phar /usr/local/bin/composer
composer --version
The output should start with "Composer version 2.". As the Laravel documentation does, put the code under /srv, not /var/www/html. For a private repository, create a deploy key and register its public key with the repository:
git clone git@github.com:you/example.git /srv/example.ir
git config --global --add safe.directory /srv/example.ir
cd /srv/example.ir
export COMPOSER_ALLOW_SUPERUSER=1
composer install --no-dev --optimize-autoloader --no-interaction
...
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
INFO Discovering packages.
Can Composer reach Packagist and GitHub from a datacenter in Iran? The figures below were taken on September 16, 2026 from one of the servers that run our own services, in that same datacenter and on the same network as the cloud servers. That machine is not a customer server, so read the numbers as "a sample from that network", not as a guarantee for your own server; the times are rounded:
curl -sS -o /dev/null -m 12 -w 'HTTP %{http_code} %{http_version} %{time_total}s\n' https://repo.packagist.org/packages.json
HTTP 200 2 0.833s
curl -sS -o /dev/null -m 12 -w 'HTTP %{http_code} %{http_version} %{time_total}s\n' https://github.com/
HTTP 200 2 0.906s
curl -sS -o /dev/null -m 12 -w 'HTTP %{http_code} %{http_version} %{time_total}s\n' https://archive.ubuntu.com/ubuntu/
HTTP 200 1.1 0.361s
In other words, on a normal day Packagist, GitHub and the Ubuntu repositories answer with a 200, and the average RTT to github.com was about 85 ms. Run the same three commands on your own server too, because different IP ranges don't take the same route. If you have a mirror, put its address in place of MIRROR_URL: composer config repos.packagist composer MIRROR_URL, and to go back, composer config --unset repos.packagist.
composer install won't work from a server in Iran, because Packagist and GitHub are foreign destinations. The right approach is to keep composer.lock in the repository, build vendor/ on your own machine or on a server outside Iran, and transfer it with rsync. Because the server and its visitors are both inside Iran, the site's own traffic doesn't pass through the international gateway.Setting up the .env file and Laravel storage permissions
The .env file is never in the repository. Create it on the server from the example and change four values: APP_ENV=production, APP_DEBUG=false, APP_URL=https://example.ir and the database details from the next section. The Laravel configuration documentation is explicit about APP_DEBUG: always false in production, otherwise the error page exposes sensitive values to your users.
cp .env.example .env
nano .env # APP_ENV, APP_DEBUG, APP_URL, DB_*
php artisan key:generate --force
INFO Application key set successfully.
chown -R www-data:www-data /srv/example.ir
chmod -R 775 /srv/example.ir/storage /srv/example.ir/bootstrap/cache
php artisan storage:link
INFO The [public/storage] link has been connected to [storage/app/public].
On Ubuntu, PHP-FPM runs as the www-data user (/etc/php/8.3/fpm/pool.d/www.conf), and Laravel only needs to write to storage and bootstrap/cache; don't run the chmod -R 777 you see on forums. Before the site goes public, switch SSH to key-based login and open ufw only on ports 22, 80 and 443.
What does a Laravel Nginx configuration look like?
The block below is the example from the Laravel 13 deployment documentation, plus default_server, the domain name, the /run/php/… socket (on 22.04 this is the same as the documentation's /var/run) and four lines that set buffers and remove X-Powered-By. The three lines that matter most: root pointing at public, try_files falling back to index.php, and fastcgi_pass to the PHP-FPM 8.3 socket:
# /etc/nginx/sites-available/example.ir
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name example.ir www.example.ir;
root /srv/example.ir/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
ln -s /etc/nginx/sites-available/example.ir /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
systemctl reload nginx
Removing sites-enabled/default is mandatory, because that file also declares a default_server on port 80 and nginx -t fails with "duplicate default server". The last block shuts every dot path, such as .env and .git, behind a 403 and leaves only .well-known open for SSL.
Where to create the MariaDB or PostgreSQL database, and how to run migrations
MariaDB was installed in an earlier step, and on Ubuntu 22.04 it is version 10.6; its root user logs in without a password from a root terminal:
mariadb -e "CREATE DATABASE example_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb -e "CREATE USER 'example_user'@'localhost' IDENTIFIED BY 'a-strong-password';"
mariadb -e "GRANT ALL PRIVILEGES ON example_db.* TO 'example_user'@'localhost'; FLUSH PRIVILEGES;"
In .env, set DB_CONNECTION=mariadb (or mysql), DB_HOST=127.0.0.1, and the database name, user and password. If the project needs PostgreSQL, install it with apt together with the php8.3-pgsql driver; on 22.04 that gives you version 14:
apt install -y postgresql php8.3-pgsql
sudo -u postgres psql -c "CREATE USER example_user WITH PASSWORD 'a-strong-password';"
sudo -u postgres psql -c "CREATE DATABASE example_db OWNER example_user;"
systemctl restart php8.3-fpm
Then use DB_CONNECTION=pgsql and DB_PORT=5432. Either way, on a production server migrate without --force only asks you a question:
php artisan migrate --force
INFO Preparing database.
Creating migration table ........................................ 14.21ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ............................ 31.08ms DONE
0001_01_01_000001_create_cache_table ............................. 9.65ms DONE
0001_01_01_000002_create_jobs_table ............................. 16.40ms DONE
In Laravel 13 the default queue and cache driver is database, so the queue in the next section runs on this same database with no extra service.
Keeping the Laravel queue worker running with systemd
The Laravel documentation recommends Supervisor for queue:work; systemd does the same job with no extra package: it starts the worker at boot and brings back a process that dies:
# /etc/systemd/system/laravel-queue.service
[Unit]
Description=Laravel queue worker (example.ir)
After=network.target mariadb.service
[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/example.ir
ExecStart=/usr/bin/php /srv/example.ir/artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now laravel-queue
systemctl status laravel-queue --no-pager
● laravel-queue.service - Laravel queue worker (example.ir)
Loaded: loaded (/etc/systemd/system/laravel-queue.service; enabled; vendor preset: enabled)
Active: active (running) since Wed 2026-09-16 10:14:03 +0330; 6s ago
Main PID: 21437 (php)
The options come from the Laravel queue documentation: --sleep=3 is the pause when the queue is empty, --tries=3 the maximum number of attempts per job, and --max-time=3600 makes the worker exit every hour so memory leaks can't pile up; Restart=always then starts a fresh worker. For the Redis driver, apt install -y redis-server php8.3-redis and QUEUE_CONNECTION=redis are all you need.
php artisan queue:restart so the running worker exits after its current job and systemd starts the new version; the Laravel 13 documentation also has a php artisan reload command that sends the same signal to every long-running service.Running the Laravel scheduler with cron
All of Laravel's scheduling is defined in routes/console.php, and on the server you only need one cron line that calls schedule:run every minute. Add it to www-data's crontab, not root's, so the files your jobs write have the right owner:
crontab -u www-data -e
* * * * * cd /srv/example.ir && php artisan schedule:run >> /dev/null 2>&1
cd /srv/example.ir && sudo -u www-data php artisan schedule:run
INFO No scheduled commands are ready to run.
"No scheduled commands are ready to run" means the scheduler is healthy and simply had nothing to do this minute; php artisan schedule:list shows the jobs with their next run time. The syntax of the cron fields is explained in the Linux cron job guide. In maintenance mode (artisan down), no scheduled task runs unless it is marked with evenInMaintenanceMode().
Connecting a domain and free SSL to your Laravel project
One A record for example.ir and one for www are enough (the examples use a .ir domain, Iran's country-code domain; any domain works the same way). In Mehran Host's free DNS, lower the TTL from the default 3600 to 60 when you create the record so the move takes effect quickly, then set it back to 3600 once the site is stable; what each record type is for is explained in our DNS records tutorial. Certbot isn't installed on a fresh image, and the version in the 22.04 repository is old, so install it from snap:
apt install -y snapd
snap install core && snap refresh core
snap install --classic certbot
ln -s /snap/bin/certbot /usr/bin/certbot
Once dig +short A example.ir returns the server's IP, request the certificate:
certbot --nginx -d example.ir -d www.example.ir
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.ir/fullchain.pem
...
Congratulations! You have successfully enabled HTTPS on https://example.ir and https://www.example.ir
The snap version sets up the 443 block, the redirect from port 80 and automatic renewal on its own; common issuance errors are covered in installing free SSL with Certbot. After SSL is in place, APP_URL must start with https, otherwise asset() links stay on http. The Mehran Host content delivery network switch next to that same A record is optional (the first 1 TB of CDN traffic is free), but once it's on, the public record points to the edge nodes and the CDN itself issues and renews the certificate. Certbot renewal on the origin then no longer has an HTTP-01 path and fails silently. Choose one or the other.
A Laravel deploy script with Git and the shortest possible downtime
After the first time, deploying Laravel to a VPS means exactly this sequence, and it belongs in a single file so queue:restart never gets forgotten. Save the script below as /usr/local/bin/deploy-example, make it executable and run it as root:
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/example.ir
SECRET=$(openssl rand -hex 12)
export COMPOSER_ALLOW_SUPERUSER=1
cd "$APP"
trap 'php artisan up' EXIT
php artisan down --render="errors::503" --secret="$SECRET" --retry=60
echo "bypass while down: https://example.ir/$SECRET"
git pull --ff-only
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan optimize
php artisan queue:restart
systemctl reload php8.3-fpm
chown -R www-data:www-data "$APP/storage" "$APP/bootstrap/cache"
down --secret takes the site offline with a 503 status and gives you a secret URL so you can see the new version before anyone else; --render pre-renders the 503 page before the dependencies load, so nobody sees a raw error while composer install runs. The trap comes before down on purpose, so an interrupted script doesn't leave the site down. But it cuts both ways: if composer install or the migration fails after git pull, artisan up brings the site back with new code and the old vendor directory, and the result is a 500; on a critical project, set the trap on ERR instead. This approach has a few seconds of downtime; true zero downtime means a fresh release directory and a symlink switch. optimize builds the config, event, route and view caches:
php artisan optimize
INFO Caching framework bootstrap, configuration, and metadata.
config ......................................................... 22.15ms DONE
events .......................................................... 3.62ms DONE
routes ......................................................... 17.80ms DONE
views ......................................................... 131.09ms DONE
Reloading php8.3-fpm flushes the opcode cache, and the final chown hands files created as root back to www-data. Without it, the first request after a deployment breaks with "Permission denied" when it tries to write to storage/logs/laravel.log or storage/framework.
Laravel 500 error or blank page after deployment: where to start
A Laravel 500 error after deployment is almost always one of the five causes below, and the real error is written in one of two files: storage/logs/laravel.log or /var/log/nginx/error.log. Read those two first:
tail -n 30 /srv/example.ir/storage/logs/laravel.log
tail -n 30 /var/log/nginx/error.log
sudo -u www-data php artisan about --only=environment
- "No application encryption key has been specified" —
.envhas noAPP_KEY; runphp artisan key:generate --force, and if the config is cached,php artisan config:cache. - A blank page and "Permission denied" on storage/logs or bootstrap/cache — the files are owned by root; repeat the
chownandchmodfrom the .env section. - 403 Forbidden from Nginx — the config's
rootpoints at the project root instead ofpublic, or the default site is still enabled. - Changes to .env have no effect — the config cache is stale; run
php artisan config:clear, then cache again. - 502 Bad Gateway — Nginx can't reach the
php8.3-fpmsocket; checksystemctl status php8.3-fpmand that the socket path matchesfastcgi_pass. Full troubleshooting is in a separate article, linked in the sources at the bottom of this page.
Don't set APP_DEBUG to true on a live server; the same error, with full details, is already in laravel.log. The general checklist for 500 errors is in fixing the 500 Internal Server Error.
This whole path, from a bare server to a live site with a queue and cron, takes under two hours on a 2 GB server. If the project is for a client, the server stays and is billed by the hour; if it was only an experiment, delete it rather than just powering it off.
Frequently asked questions
Does Laravel run on shared hosting, or do I need a VPS?
A simple Laravel site can run on shared hosting, but a real project needs a VPS, because shared hosting lacks four things: a queue worker (queue:work) that stays on permanently, a scheduler that needs a once-a-minute cron job with SSH access, Composer for installing packages, and control over the PHP version. As soon as you use jobs, queued email or Horizon, you need a VPS with root access. An hourly cloud server suits testing this, because only the hours used are billed and you can delete it afterwards.
Does Laravel 13 run on Ubuntu 22.04, whose default PHP is 8.1?
Yes, but not with the default PHP. Laravel 13 needs PHP 8.3 or later, while Ubuntu 22.04 ships 8.1; add the ondrej PPA and install the php8.3-fpm and php8.3-cli packages, which is what the one-click "PHP 8.3" app does when you create a server at Mehran Host. Be careful: the one-click "LEMP stack" installs the distribution's PHP, 8.1, which is not enough for Laravel 13. After installing, check the version with php -v.
How much RAM, CPU and disk does a Laravel deployment need?
A typical Laravel project running Nginx, PHP-FPM and MariaDB on one server is comfortable with 2 GB of RAM and 20 GB of disk. For testing, 1 GB is enough, but composer update may run out of memory there; run composer install with the lock file on the server, or add swap. With Redis, queue workers or high traffic, 4 GB is safer. Mehran Host's server builder accepts 1–10 GB of RAM, 1–8 cores and 10–100 GB of disk, and you can upgrade later without reinstalling.
How do I keep the Laravel queue worker running all the time?
Run the queue worker as a systemd service, so it starts when the server boots and comes back automatically if it stops: create a unit file with ExecStart set to php artisan queue:work and Restart=always, then enable it with systemctl enable --now. After every deployment, run php artisan queue:restart so the worker loads the new code. Supervisor, which the Laravel documentation recommends, does the same job; systemd is already installed on Ubuntu and needs no extra package.
Why do I get a 500 error or a blank page after deploying Laravel?
Usually one of three things: permissions on the storage and bootstrap/cache directories, which must be owned by the www-data user; a missing APP_KEY in the .env file, which php artisan key:generate creates; or a stale config cache, which php artisan config:clear removes. Read storage/logs/laravel.log first and then the Nginx error log; the real error is almost always written in one of the two, and if neither shows anything, check the PHP-FPM log. Keep APP_DEBUG false on a live server.