LARAVEL
July 9, 2026

How to Configure Nginx for a Laravel Application

6 min read
Author
CloudStick Team
DevOps Engineer
Share this article
How to Configure Nginx for a Laravel Application
CloudStick
Nginx for Laravel

The canonical Laravel server block

A working Laravel site needs exactly three things from Nginx: a root pointing at the public directory, a try_files rule that funnels every non-file request into index.php, and a PHP location block that hands those requests to PHP-FPM. This is the server block Laravel's own documentation recommends, and it is the right starting point for any VPS:

server {
listen 80;
server_name example.com;
root /home/appuser/apps/myapp/public;
index index.php;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}

The try_files line is the heart of it: serve the request as a real file if one exists (a CSS bundle, an image), as a directory if that matches, and otherwise rewrite it to index.php with the query string intact so Laravel's router takes over. The final location block denies access to all dotfiles except .well-known, which Let's Encrypt needs for HTTP validation.

PREREQUISITE

This article assumes Nginx and PHP-FPM 8.3 are already installed and your Laravel app is on the server with dependencies installed. If you are starting from a blank VPS, work through the full deployment guide first — the Nginx config is step five of that process, not step one.

Why the document root must be /public

Pointing root at the project directory instead of public is the single most dangerous Nginx mistake a Laravel deploy can make, because it exposes your .env file to anyone who requests example.com/.env. That file holds your APP_KEY, database password, and every third-party API credential the app uses — and automated scanners request that exact path on every new domain they discover, usually within hours of the DNS record appearing.

Laravel is built on the assumption that only public/ is web-accessible: index.php lives there, compiled assets are written there by Vite, and everything else — app code, vendor packages, storage, configuration — sits one level above, out of reach. The deny-all dotfile rule in the block above is a second line of defense, not a substitute. If your app currently lives at a root without /public, do not work around it with rewrites; move the root. It is a one-line change and it closes the whole class of leaks at once.

Connect Nginx to PHP-FPM correctly

The fastcgi_pass directive must match a socket that actually exists, and a mismatch here is the classic cause of a 502 Bad Gateway on a fresh Laravel deploy. Confirm where your PHP-FPM pool listens before trusting any copied config:

$ grep "^listen" /etc/php/8.3/fpm/pool.d/www.conf
listen = /run/php/php8.3-fpm.sock
$ ls -l /run/php/
srw-rw---- 1 www-data www-data 0 Jul 9 09:14 php8.3-fpm.sock

One detail in the PHP location block matters more than it looks: using $realpath_root instead of $document_root in SCRIPT_FILENAME. If you ever adopt symlink-based zero-downtime deploys, $realpath_root resolves the symlink on every request so OPcache sees real release paths — with $document_root, PHP keeps serving the old release after a deploy until you reload FPM. Setting it correctly now costs nothing and saves a confusing afternoon later.

Tune uploads, gzip, and static asset caching

Three additions cover most real-world needs. Nginx's default 1 MB body limit rejects file uploads with a 413 error, so raise client_max_body_size to match your app's upload_max_filesize in PHP. Gzip cuts text responses by 70 percent or more. And Vite emits fingerprinted asset filenames, which makes long cache lifetimes free — the filename changes whenever the content does:

client_max_body_size 64m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location ~* \.(css|js|jpg|jpeg|png|gif|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}

Turning off access logging for static assets keeps your logs readable — one page view stops generating twenty log lines for its images and scripts, so the requests you actually debug with stay visible.

Add HTTPS and redirect HTTP

Production means two server blocks: a minimal port-80 block whose only job is redirecting to HTTPS, and the real block on 443 carrying the certificate and everything configured above:

server {
listen 80;
server_name example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ...root, locations, and tuning from above...
}

Certbot's Nginx plugin (certbot --nginx -d example.com) issues the certificate and writes this split for you, then renews automatically. If the app generates http:// links behind HTTPS, that is Laravel not trusting the proxy headers — fix it in the app's TrustProxies middleware, not with more Nginx rewrites.

Test, reload, and next steps

Never restart Nginx on a config change — validate first, then reload. nginx -t catches syntax errors before they take the site down, and reload applies the change without dropping a single connection:

$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx

Then verify the three behaviors that matter: the homepage loads over HTTPS, a deep link like example.com/login works when requested directly (that is try_files doing its job), and example.com/.env returns 403 or 404 — never file contents.

If you would rather not maintain this by hand, this entire configuration is what CloudStick generates when you create a PHP web application in its dashboard: a hardened Nginx vhost with the public root, PHP-FPM socket wiring, security headers, and free Let's Encrypt SSL with auto-renewal — plus a per-site custom-config zone where overrides like a bigger upload limit survive upgrades. Either way, the server block above is the contract: root at public, try_files into index.php, and PHP-FPM on a socket that exists.

Leave a comment
Full Name
Email Address
Message
Contents