Skip to content
ohsudo, Home
Networking

Nginx for Laravel with PHP-FPM: production config, permissions and queues

Deploy Laravel on Nginx and PHP-FPM: a complete HTTPS config pointing at public, permissions for storage, the optimize and queue steps, and the errors you may see.

Intermediate3 min readApplies to Nginx 1.18+ · PHP 8.2+ · Laravel 10+ · Ubuntu · Debian

by OhSudo ·

The most important line in this config is root: it must point at Laravel’s public folder, not the project folder. With the wrong one, your .env, with its database password and app key, can be downloaded by anyone.

The Nginx Config Generator with these options writes the HTTPS and redirect parts for your domain. It doesn’t write PHP, so add the location blocks below.

The config

server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/myapp/public;
index index.php;
charset utf-8;
client_max_body_size 64m;
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;
# Only index.php runs, and no other PHP file.
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;
}
# Hidden files such as .env, but not Let's Encrypt's folder.
location ~ /\.(?!well-known).* {
deny all;
}
}

1. Install PHP

Terminal window
sudo apt install nginx php-fpm php-mysql php-mbstring php-xml php-curl php-zip php-bcmath composer
php -v
ls /run/php/

Change php8.3-fpm.sock in the config if your version differs. A wrong socket name is the usual reason for a 502.

2. Deploy the code

Terminal window
cd /var/www/myapp
composer install --no-dev --optimize-autoloader
cp .env.example .env # first time only, then fill it in
php artisan key:generate # first time only
php artisan migrate --force
npm ci && npm run build

In .env, for production:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

APP_DEBUG=true in production shows your settings and passwords on any error page, so check that one twice.

3. Permissions

PHP has to write in storage and bootstrap/cache, and nowhere else:

Terminal window
sudo chown -R root:www-data /var/www/myapp
sudo find /var/www/myapp -type d -exec chmod 750 {} +
sudo find /var/www/myapp -type f -exec chmod 640 {} +
sudo chown -R www-data:www-data /var/www/myapp/storage /var/www/myapp/bootstrap/cache

Files uploaded by users need a link from public to storage, as Nginx only serves public:

Terminal window
php artisan storage:link

The chmod Calculator explains these numbers.

4. Speed it up

Each of these needs running again after a deploy, or the old values stay in the cache:

Terminal window
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

After config:cache, the app no longer reads .env directly, so calling env() outside config files returns null. Then restart PHP-FPM, which also clears OPcache:

Terminal window
sudo systemctl reload php8.3-fpm

5. Queues and the scheduler

These don’t run by themselves. For the queue, use a service in /etc/systemd/system/myapp-queue.service:

[Unit]
Description=Laravel queue worker
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
[Install]
WantedBy=multi-user.target

The worker keeps your code in memory, so restart it on each deploy, with php artisan queue:restart. The systemd Unit Generator can write a fuller unit.

For the scheduler, add this line with sudo crontab -u www-data -e:

* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1

If it doesn’t run, see cron job not running.

6. Get HTTPS, and turn it on

Terminal window
sudo certbot certonly --standalone -d example.com \
--pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

If that fails, see Certbot failed to authenticate.

Behind Cloudflare or a load balancer, tell Laravel to trust it, or it builds http:// links and can redirect in a loop. Since Laravel 11, this goes in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['10.0.0.0/8']);
})

Use your proxy’s real address range rather than * where you can.

When it goes wrong

You see Means Go to
500, with an empty page Read storage/logs/laravel.log Often permissions, as in section 3
The stream or file … could not be opened … Permission denied PHP can’t write in storage Section 3
404 on every route but / No try_files The config above
403 Forbidden root isn’t the public folder, or wrong permissions Nginx 403
502 Bad Gateway Wrong PHP socket, or PHP-FPM stopped Nginx 502
419 Page Expired A session or CSRF problem: check APP_URL, SESSION_DOMAIN and HTTPS Section 6
No application encryption key Run php artisan key:generate, then config:cache Section 2
SQLSTATE[HY000] [2002] Can’t reach the database MySQL socket

Warning

Check that .env really is out of reach: curl -I https://example.com/.env should give 403 or 404. Then also make sure it isn’t in git. If it has ever been public, change the app key and every password in it.

Found this useful? Share it.

Found a mistake? Let us know at contact@ohsudo.com.

Tools

Try it yourself

Articles

Related articles