Blog/How to Deploy Laravel with Nginx on Ubuntu (Server Block, Permissions, Queues, and Scheduler)

NginxLaravelPHPLinuxDevOps··greencrew

How to Deploy Laravel with Nginx on Ubuntu (Server Block, Permissions, Queues, and Scheduler)

To deploy Laravel with Nginx, set the server block's root to your app's public/ directory, add try_files $uri $uri/ /index.php?$query_string; to the location / block, pass index.php to PHP-FPM, and give www-data write access to storage/ and bootstrap/cache. This guide covers PHP extensions, Composer, the .env file, caching, migrations, queue workers, the scheduler, HTTPS, and common Laravel 404/500 errors.

Short answer: Install PHP-FPM with the extensions Laravel needs, install Composer, and put your app in /var/www/laravel. Run composer install --no-dev --optimize-autoloader, fill in .env with APP_ENV=production and APP_DEBUG=false, and run php artisan key:generate and php artisan migrate --force. Point Nginx's root at /var/www/laravel/public (not the project root), use try_files $uri $uri/ /index.php?$query_string;, and send index.php to the PHP-FPM socket. Finally, make storage/ and bootstrap/cache/ writable by www-data, run the queue worker under systemd, and add the scheduler cron entry.

What Does a Laravel Production Setup on Nginx Look Like?

Laravel is a PHP framework, so it runs on the same LEMP base as any PHP app: Nginx receives requests, serves files from public/ directly, and hands everything else to PHP-FPM, which runs Laravel's front controller, public/index.php. On top of that base, a real Laravel app needs a few extra pieces:

ComponentJobCovered in
NginxHTTP/HTTPS, static files, routes everything else to index.phpStep 5
PHP-FPMRuns Laravel's PHP codeStep 1
ComposerInstalls PHP dependencies from composer.lockStep 2
MySQL / PostgreSQLApplication databaseStep 3
.env fileSecrets and environment settingsStep 3
Queue workerRuns queued jobs (mail, notifications, exports)Step 7
SchedulerRuns schedule:run every minuteStep 8

If you haven't set up Nginx, MySQL, and PHP-FPM yet, follow how to install a LEMP stack on Ubuntu first and come back here.

Prerequisites

  • Ubuntu 24.04 or 22.04 with a sudo user
  • Nginx, MySQL (or PostgreSQL), and PHP-FPM installed
  • A domain pointed at the server for HTTPS
  • Your Laravel app in a Git repository

Commands use PHP 8.3, which Ubuntu 24.04 ships. Check which PHP version your Laravel release needs in the Laravel deployment and installation docs, and replace 8.3 in package names and socket paths if yours differs.

Step 1: Install the PHP Extensions Laravel Needs

sudo apt update
sudo apt install php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
  php8.3-xml php8.3-bcmath php8.3-curl php8.3-zip php8.3-intl unzip git
php -v
php -m | grep -E 'mbstring|xml|pdo_mysql|bcmath|curl|zip|intl'
PackageWhy Laravel needs it
php8.3-fpmRuns PHP for Nginx
php8.3-cliRuns php artisan and Composer
php8.3-mysqlPDO driver for MySQL/MariaDB (php8.3-pgsql for PostgreSQL)
php8.3-mbstringMultibyte strings, required by the framework
php8.3-xmlDOM and XML functions, required by the framework and many packages
php8.3-bcmath, php8.3-intlNumber and locale formatting, used by many packages
php8.3-curlHTTP client (Http::get()), external APIs
php8.3-zip, unzipLets Composer unpack packages quickly

Extensions like Ctype, Fileinfo, OpenSSL, PDO, and Tokenizer are compiled into Ubuntu's PHP packages already. If composer install complains about a missing extension, install the matching php8.3-<name> package and restart PHP-FPM.

Step 2: Install Composer and Your App

Install Composer by following the official instructions at getcomposer.org/download (they include a checksum check), then move it onto your PATH:

sudo mv composer.phar /usr/local/bin/composer
composer --version

Clone the app into a directory your deploy user owns:

sudo mkdir -p /var/www/laravel
sudo chown $USER:www-data /var/www/laravel
git clone https://github.com/you/your-laravel-app.git /var/www/laravel
cd /var/www/laravel
composer install --no-dev --optimize-autoloader

--no-dev skips test and debug packages, and --optimize-autoloader builds a class map so PHP doesn't search the filesystem for classes on every request. If you build frontend assets with Vite, run npm ci && npm run build here too, which writes the compiled files to public/build.

Step 3: Create the Database and the .env File

Create a database and a dedicated user (MySQL shown):

CREATE DATABASE laravel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'laravel'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON laravel.* TO 'laravel'@'localhost';
FLUSH PRIVILEGES;

Then create the environment file:

cp .env.example .env
php artisan key:generate
chmod 640 .env

Edit .env with the production values:

APP_NAME="My App"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

LOG_CHANNEL=daily
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=a-long-random-password

QUEUE_CONNECTION=database
SettingProduction valueWhy
APP_ENVproductionEnables production behavior; migrate asks for confirmation unless you pass --force
APP_DEBUGfalseCritical. true shows stack traces and environment details to visitors
APP_KEYSet by key:generateEncrypts cookies and sessions. Don't change it on a live app, or every session breaks
APP_URLYour HTTPS URLUsed for generated links, emails, and the storage URL
LOG_CHANNELdailyOne file per day in storage/logs/ instead of one ever-growing file

Step 4: Fix Laravel Storage Permissions

Laravel writes logs, compiled views, sessions, and caches to storage/ and bootstrap/cache/. PHP-FPM runs as www-data, so those two directories must be writable by that user. Everything else should stay read-only to the web server.

cd /var/www/laravel
sudo chown -R $USER:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
php artisan storage:link

storage:link creates the public/storage symlink so files saved on the public disk are reachable at /storage/....

Don't fix permission errors with chmod -R 777. It makes every file writable by every process on the server, and a single upload bug becomes remote code execution. Group ownership plus 775 on the two writable directories is enough.

Step 5: Configure the Nginx Server Block for Laravel

Create /etc/nginx/sites-available/laravel. This is based on the server block in the Laravel deployment docs:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/laravel/public;

    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_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
sudo ln -s /etc/nginx/sites-available/laravel /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
DirectiveWhat it does
root .../publicOnly public/ is web-accessible. .env, vendor/, and storage/ stay unreachable
try_files $uri $uri/ /index.php?$query_stringServes real files directly, sends every other URL to Laravel's router with the query string intact
location ~ ^/index\.php(/|$)Only the front controller is executed. A stray .php file in public/ won't run
$realpath_rootResolves symlinks, so symlink-based deploys (like current -> releases/42) pick up the new release
error_page 404 /index.phpLets Laravel render its own 404 page
location ~ /\.(?!well-known)Blocks dotfiles such as .git and .env, but allows Let's Encrypt challenges

The FastCGI details, socket vs TCP, and pool tuning (pm.max_children) are covered in how to configure PHP-FPM with Nginx.

Step 6: Run Migrations and Cache the Config

cd /var/www/laravel
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

On recent Laravel versions, php artisan optimize runs these cache commands in one go.

CommandWhat it cachesGotcha
config:cacheAll config/*.php into one fileAfter caching, env() returns null outside config files. Read values with config() in app code
route:cacheThe compiled route listRe-run after every deploy that changes routes
view:cacheCompiled Blade templatesNone, just faster first requests
optimize:clearClears all of the aboveUse it when "my change isn't showing up"

Test it: curl -I http://example.com should return 200 OK. Recent Laravel versions (11 and later) register a built-in health route at /up, so curl http://example.com/up is a quick check that the framework boots.

Step 7: Run the Laravel Queue Worker with systemd

If your app dispatches jobs, something has to process them. Create /etc/systemd/system/laravel-worker.service:

[Unit]
Description=Laravel queue worker
After=network.target mysql.service

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/laravel
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now laravel-worker
sudo systemctl status laravel-worker

--max-time=3600 makes the worker exit after an hour and systemd starts a fresh one, which keeps memory leaks in check. The Laravel queue docs use Supervisor instead; either works. Workers keep the old code in memory, so run php artisan queue:restart on every deploy.

Step 8: Add the Laravel Scheduler Cron Entry

Laravel's scheduler needs one cron entry that runs every minute. Add it to the www-data crontab so it writes files with the right owner:

sudo crontab -u www-data -e
* * * * * cd /var/www/laravel && php artisan schedule:run >> /dev/null 2>&1

Check it with php artisan schedule:list. See the Laravel scheduling docs for details.

Step 9: Enable HTTPS

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot edits the server block, adds the certificate, and sets up the HTTP to HTTPS redirect. Make sure APP_URL uses https://, then run php artisan config:cache again. The full walkthrough is in how to install Let's Encrypt SSL on Nginx with Certbot.

If you put Cloudflare or a load balancer in front of Nginx, configure Laravel's trusted proxies so request()->secure() and generated URLs see HTTPS.

Deploying Laravel Updates

A simple in-place deploy script:

#!/usr/bin/env bash
set -e
cd /var/www/laravel
php artisan down --retry=15
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize
php artisan queue:restart
sudo systemctl reload php8.3-fpm
php artisan up

Reloading PHP-FPM clears OPcache so the new code is served. artisan down shows a 503 maintenance page for the few seconds the deploy takes. For true zero-downtime deploys, use release directories with a current symlink (which is why the server block uses $realpath_root), or a tool like Deployer or Envoyer.

Troubleshooting Laravel on Nginx

SymptomCauseFix
Home page works, every other route returns 404try_files missing or wrong in location /Use try_files $uri $uri/ /index.php?$query_string;
Nginx shows a directory listing or 403root points at the project root, not public/Set root /var/www/laravel/public;
Blank 500 errorException with APP_DEBUG=falseRead storage/logs/laravel-*.log
The stream or file ".../laravel.log" could not be openedstorage/ not writable by www-dataStep 4 permissions; check ls -la storage
No application encryption key has been specifiedAPP_KEY emptyphp artisan key:generate, then config:cache
.env changes ignoredConfig is cachedphp artisan config:cache again
502 Bad GatewayWrong PHP-FPM socket or PHP-FPM is downls /run/php/, systemctl status php8.3-fpm
419 Page Expired on formsSession/cookie issue, often SESSION_DOMAIN or HTTPS mismatchCheck APP_URL, SESSION_DOMAIN, SESSION_SECURE_COOKIE
Jobs never runWorker not running, or old code in memorysystemctl status laravel-worker, php artisan queue:restart
413 Request Entity Too Large on uploadsNginx client_max_body_size (1 MB default)Raise it, plus upload_max_filesize and post_max_size in php.ini

More on gateway errors in how to fix 502 Bad Gateway in Nginx.

Monitor Your Laravel App

Most Laravel outages don't take Nginx down. The server keeps answering while every request returns a 500 because the database is full, a cached config points at the wrong host, or a deploy left the app in maintenance mode. You need a check that loads the site the way a visitor does.

GreenCrew monitors your site over HTTP from outside your server and alerts you on Discord, Slack, or Telegram when it fails, and gives you a public status page to share with users. Point a check at https://example.com/up (or a route that also queries the database) so a broken app counts as down, not just a dead server. Setup steps are in how to get website downtime alerts on Discord, Slack, and Telegram.

Frequently Asked Questions

Why does Nginx need to point at Laravel's public folder?

public/ contains only index.php and your assets. Pointing root at the project root would make .env, vendor/, storage/, and your source code downloadable or executable over HTTP. With root on public/, those files are outside the web root.

Should I use Laravel Forge or set up Nginx manually?

Forge and similar tools provision this exact stack (Nginx, PHP-FPM, queue workers, scheduler, Certbot) for you. Setting it up manually once is worth it so you know what's running and can debug it, and it costs nothing beyond the server.

Do I need Supervisor for Laravel queues?

No. Laravel's docs use Supervisor, but systemd is already on every Ubuntu server and does the same job: it starts the worker at boot and restarts it when it exits. Use whichever you're comfortable maintaining.

How do I fix "Permission denied" errors in storage after a deploy?

Files created by your deploy user (for example, when you ran artisan as yourself) can end up unwritable by www-data. Re-run sudo chown -R $USER:www-data storage bootstrap/cache && sudo chmod -R 775 storage bootstrap/cache, and run artisan commands that write cache files as the same user consistently.

Conclusion

Deploying Laravel with Nginx on Ubuntu comes down to a few rules: point root at public/, route everything through index.php with try_files, keep APP_DEBUG=false, make only storage/ and bootstrap/cache/ writable, and cache your config on each deploy. Add a systemd queue worker, the scheduler cron entry, and HTTPS, and you have a production setup. Then add an external check with GreenCrew on your /up route so you hear about a 500 error before your users do.