The Nginx configuration Laravel needs, and why each line is there
A complete Nginx server block for Laravel with PHP-FPM, explained line by line, with fixes for 404s, "File not found", subdirectories and Reverb.
Jacob Søndergaard ·
Laravel needs little from Nginx: serve the files in public/, send everything else to index.php, and keep the rest of the project out of reach. The common mistakes are in the details around that: a dropped query string, a path variable that ignores symlinked releases, permissions when PHP-FPM runs as its own user. This post gives a complete server block, explains each line, and shows how each wrong line fails, with the error it leaves in the log.
The site is the one used throughout the series: the app in /var/www/example.com, a current symlink to the live release (from the zero-downtime deploy post), and a PHP-FPM pool running as the site's account, example. Every config here passed nginx -t and every failure was reproduced on Ubuntu 26.04 (Nginx 1.28, PHP 8.5) and 24.04 (Nginx 1.24, PHP 8.3).
A complete Nginx configuration for Laravel
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/current/public;
index index.php;
charset utf-8;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 20m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
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; }
location ^~ /build/assets/ {
expires max;
access_log off;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/example.com.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_hide_header X-Powered-By;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}sudo nano /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo nginx -t
sudo systemctl reload nginxThis serves plain HTTP. certbot adds the HTTPS server block and the redirect to this file when it issues the certificate.
root at public/
root is the only directory Nginx serves files from, and for Laravel it has to be public/. Point it at the project root instead and two things happen. Most of the repository becomes downloadable, composer.json and config/app.php included. And PHP stops working, because Nginx now looks for index.php next to artisan, where there is none: every route answers File not found.
index index.php makes a request for / go to index.php. charset utf-8 adds the charset to text responses Nginx serves itself. The two log lines give the site its own logs, which is where every diagnosis below starts. client_max_body_size is the largest request body Nginx accepts, 1 MB by default, and a bigger upload fails with 413 before PHP sees it. Keep it in line with upload_max_filesize and post_max_size in PHP.
try_files and the query string
try_files $uri $uri/ /index.php?$query_string is the line that makes Laravel's routes work. Nginx serves the file if it exists, then the directory, and otherwise hands the request to index.php.
The ?$query_string is the part people drop. Without it, the internal redirect to /index.php carries no arguments, so request('x') is empty for /search?x=1. Pages still render, which is why this survives for weeks. Pagination and search stop working.
error_page 404 /index.php sends Nginx's own 404s, such as a missing favicon.ico, to Laravel so the visitor sees your 404 page. It also hides one mistake: with try_files $uri $uri/ =404, every route still renders through this line, but with status 404. Browsers show the page, while search engines and API clients see an error.
The two exact-match locations stop favicon.ico and robots.txt from filling the logs. /build/assets/ holds Vite's output, whose file names contain a hash of their contents, so the browser may cache them forever.
The X-Content-Type-Options header appears twice on purpose. A location with an add_header of its own inherits none from the server block, so adding Cache-Control would otherwise drop the security headers for every asset. always adds a header to error responses too.
The PHP location and SCRIPT_FILENAME
location ~ ^/index\.php(/|$) passes only index.php to PHP. An uploaded shell.php in public/storage is sent back as a file and never executed. This is the form Laravel's deployment documentation uses, and it is stricter than the ~ \.php$ found in most tutorials.
- fastcgi_pass. The site's own PHP-FPM socket, covered below.
- fastcgi_split_path_info and PATH_INFO. Split a URL such as /index.php/users into the script and the rest, so PHP is handed the real script path rather than having to work it out from cgi.fix_pathinfo.
- include fastcgi_params. The standard request variables. On Ubuntu this file does not set SCRIPT_FILENAME, so the next line is not optional. Leave it out and every request answers File not found.
- SCRIPT_FILENAME and DOCUMENT_ROOT. The file PHP runs, built from $realpath_root. The next section explains why that variable.
- fastcgi_hide_header X-Powered-By. Stops every response advertising the PHP version.
- fastcgi_buffer_size and fastcgi_buffers. Room for response headers. Nginx keeps them in one buffer of a single memory page by default. A session cookie plus Inertia's headers plus a package or two can exceed that, and Nginx then answers 502 with upstream sent too big header while PHP logs nothing.
Ubuntu also ships snippets/fastcgi-php.conf, which includes fastcgi.conf and sets SCRIPT_FILENAME from $document_root. It works if you override that parameter afterwards, but writing the lines out leaves no doubt about which value PHP receives.
$realpath_root with symlinked releases
With a current symlink, $document_root is /var/www/example.com/current/public, the same string before and after a deploy. PHP's OPcache and realpath cache see the same path and keep serving the release they already know. $realpath_root is the same directory with the symlink resolved, /var/www/example.com/releases/20261021113519/public, so each release has paths of its own. Two server blocks on the same pool, one with each variable, after the symlink was swapped by hand:
after the swap, with no PHP-FPM reload:
$realpath_root the new release, from the next request on
$document_root the previous release, still after 200 secondsReloading PHP-FPM on every deploy clears those caches too, and the deploy script does both. With $realpath_root the swap is correct even before the reload.
Resolving the path means Nginx, running as www-data, must be able to enter every directory on it. That is why the site directory is mode 750 with group www-data. With mode 700, PHP requests fail with File not found. and static files with 403.
Per-site PHP-FPM sockets
Ubuntu's default pool, www, runs every site as www-data. A pool per site runs each one as its own account, so one compromised app cannot read another's .env, and each gets its own process limits and, if you need it, its own PHP version.
[example.com]
user = example
group = example
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500sudo tee /etc/php/8.5/fpm/pool.d/example.com.conf > /dev/null <<'EOF'
[example.com]
user = example
group = example
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
EOF
sudo php-fpm8.5 -t
sudo systemctl reload php8.5-fpmThe socket belongs to www-data with mode 0660, so Nginx can connect and other site accounts cannot. pm.max_requests recycles a PHP worker after 500 requests, which contains slow memory leaks.
$ ls -l /run/php/
srw-rw---- 1 www-data www-data 0 Oct 21 13:33 example.com.sock
srw-rw---- 1 www-data www-data 0 Oct 21 13:27 php8.5-fpm.sockDenying dotfiles
location ~ /\.(?!well-known).* answers 403 for any path with a segment starting with a dot: .env, .git, .htaccess. The exception is /.well-known/, where Let's Encrypt places its HTTP challenge. Laravel keeps .env outside public/, so this is a second line of defence for the day something ends up in the wrong place.
Fixing 404 Not Found and "File not found"
Start with the site's error log. The bare File not found. page comes from PHP-FPM, not Nginx, and the log says which side gave up.
sudo tail -f /var/log/nginx/example.com.error.log- File not found. with "Primary script unknown". PHP-FPM could not open the file in SCRIPT_FILENAME. Causes seen here: root not ending in public, the SCRIPT_FILENAME line missing, Nginx unable to enter a directory on the path, or current briefly missing during a non-atomic symlink swap.
FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream, client: 127.0.0.1, server: example.com, request: "GET /release HTTP/1.1", upstream: "fastcgi://unix:/run/php/example.com.sock:" - Laravel's 404 page on every route but the home page. try_files has no /index.php fallback, or ends in =404.
- Nginx's own 404 page, or the "Welcome to nginx" page. No server block matched the host name, so the default site answered. Check server_name and that the file is linked into sites-enabled.
- 502 Bad Gateway. Nginx could not reach PHP-FPM: a wrong socket path, or the pool is not running. ls -l /run/php/ shows which sockets exist.
connect() to unix:/run/php/php-fpm-missing.sock failed (2: No such file or directory) while connecting to upstream - 403 on CSS, images and other static files. Nginx cannot read them.
open() "/var/www/example.com/current/public/favicon.ico" failed (13: Permission denied)
Serving Laravel from a subdirectory
If you can, use a subdomain instead. It is a second ordinary server block and Laravel never knows the difference. When the app has to live at example.com/blog, deploy it like any other site, here to /var/www/blog.example.com with its own pool, and add this to the main site's server block, above location /:
location = /blog {
return 301 /blog/;
}
location ^~ /blog/ {
alias /var/www/blog.example.com/current/public/;
try_files $uri $uri/ @blog;
location ~ ^/blog/index\.php(/|$) {
fastcgi_pass unix:/run/php/blog.example.com.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_hide_header X-Powered-By;
}
location ~ /\. {
deny all;
}
}
location @blog {
rewrite ^ /blog/index.php last;
}- alias with a trailing slash. /blog/build/app.css maps to public/build/app.css. The location and the alias both end in a slash, or the paths join wrongly.
- ^~. Stops Nginx from checking the server's regex locations for anything under /blog/, so the main site's PHP location never sees the sub-app's requests. It also skips the server's dotfile rule, which is why the block carries its own.
- try_files to a named location. try_files $uri $uri/ /blog/index.php?$query_string looks right and does not work with alias, a long-standing Nginx bug. Nginx joins the alias and the file name without the slash and logs this:
open() "/var/www/example.com/current/publicindex.php" failed (2: No such file or directory)The named location @blog rewrites to /blog/index.php instead, and rewrite keeps the query string on its own. SCRIPT_FILENAME is $realpath_root/index.php, because with alias the usual $fastcgi_script_name still starts with /blog.
Laravel works out its base path from SCRIPT_NAME, which fastcgi_params sets to /blog/index.php. Routes then match without the prefix, and url(), route() and Vite's asset URLs include it. On the test server /blog/release reached the route /release, and the page linked its assets under /blog/build/assets/.
Nginx config for Laravel Reverb
Reverb is a WebSocket server that runs next to the app, here on 127.0.0.1:8080. Browsers connect to /app/<key> and your app publishes through /apps/<id>, so Nginx proxies those two paths and leaves the rest to PHP. WebSockets need the Upgrade and Connection headers passed on, and the map that computes Connection belongs in the http context, which on Ubuntu means a file in conf.d:
sudo tee /etc/nginx/conf.d/connection-upgrade.conf > /dev/null <<'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
EOFThen this location in the server block, above location /:
location ~ ^/(app|apps)(/|$) {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}- An anchored regex. location /app would also catch /application and /apps-directory. With ^/(app|apps)(/|$), /application stays with Laravel.
- proxy_http_version 1.1. The upgrade does not exist in HTTP/1.0, which is Nginx's default towards a proxied server.
- proxy_buffering off and the timeouts. Messages go out as they arrive, and a quiet connection is not cut at Nginx's 60-second default.
Reverb itself runs as a service, the same shape as the queue worker units:
[Unit]
Description=example.com Reverb
After=network.target
[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan reverb:start --host=127.0.0.1 --port=8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetsudo tee /etc/systemd/system/example-reverb.service > /dev/null <<'EOF'
[Unit]
Description=example.com Reverb
After=network.target
[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan reverb:start --host=127.0.0.1 --port=8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now example-reverb
sudo nginx -t && sudo systemctl reload nginxIn shared/.env, the REVERB_SERVER_* values say where Reverb listens, and REVERB_HOST, REVERB_PORT and REVERB_SCHEME say where browsers and the app reach it, which is through Nginx:
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
REVERB_HOST=example.com
REVERB_PORT=443
REVERB_SCHEME=httpsA WebSocket handshake through Nginx should answer 101 and then Reverb's first message:
$ curl -si -N --max-time 3 --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
"http://example.com/app/$REVERB_APP_KEY?protocol=7&client=js&version=8.4.0"
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
{"event":"pusher:connection_established","data":"{\"socket_id\":\"3606599.910392284\",\"activity_timeout\":30}"}Add php artisan reverb:restart to your deploy next to the queue restart, so Reverb picks up the new release.
Testing with nginx -t
nginx -t parses the whole configuration, checks every file it includes, and reports the first error with its file and line. Run it before every reload:
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulsudo nginx -t && sudo systemctl reload nginxA reload whose configuration fails is refused, and the old configuration keeps running, so a typo does not take the site down. A restart with the same typo does: Nginx stops and fails to start. Use reload. nginx -T prints the full configuration as Nginx sees it, with every include expanded, which shows which file a directive comes from.
What Provisore does here
Every Laravel site Provisore creates gets its own PHP-FPM pool and socket running as the site's account, and an Nginx vhost rooted at public/ with the try_files block above (see Runtimes). Redirects, forced HTTPS and Basic Auth are site settings that update the vhost without a redeploy, and every change is validated before Nginx reloads, so a configuration Nginx would reject is never left in place. Each deploy renders the vhost again from the template, so lasting changes belong in the site's settings rather than in the file.