The first time I deployed a Laravel app to a production server, I made the classic beginner mistake: I pointed Nginx’s document root straight at the project folder instead of the public directory. Suddenly my .env file, my app folder, and my entire codebase were browsable from the internet. Nobody exploited it before I caught it, but it scared me enough that I’ve never made that mistake again — and I’ve made sure to explain it clearly every time I write about this topic since.
This guide covers everything you need to correctly and securely serve a Laravel application with Nginx and PHP-FPM.
Why Nginx for Laravel?
Laravel doesn’t ship with its own production-grade web server (the php artisan serve command is explicitly for local development only). In production, the standard, battle-tested combination is Nginx as the web server, paired with PHP-FPM (FastCGI Process Manager) to actually execute your PHP code. Nginx handles incoming HTTP requests, serves static files directly, and passes anything that needs PHP execution off to PHP-FPM over a socket or TCP connection.
This combination is popular because Nginx is lightweight and handles concurrent connections efficiently, while PHP-FPM gives you fine-grained control over how many PHP worker processes run and how they’re managed.
Requirements
- A Linux server (Ubuntu 22.04/24.04 examples here)
- Nginx installed
- PHP-FPM installed, matching the PHP version your Laravel app needs (Laravel 11 requires PHP 8.2+, for example — check your
composer.json) - Your Laravel application deployed to the server, with
composer install --optimize-autoloader --no-devalready run - Correct file permissions on the
storageandbootstrap/cachedirectories
Install PHP-FPM and common Laravel extensions:
sudo apt update
sudo apt install php-fpm php-mysql php-mbstring php-xml php-curl php-zip php-bcmath php-gd -y
Confirm PHP-FPM is running:
sudo systemctl status php8.2-fpm
(Adjust the version number to match what’s installed — php -v will tell you.)
Understanding the Document Root Rule
This is the single most important thing to get right, so I’ll say it plainly: Nginx’s root must point to Laravel’s public directory, never to the project root.
Laravel’s public/index.php file is the only PHP file meant to be publicly reachable. Everything else — your .env file with database credentials, your app directory with your business logic, your vendor directory with third-party code — must never be directly accessible over HTTP. Laravel’s architecture assumes this boundary is enforced by the web server config, not by Laravel itself.
/var/www/myapp/ ← project root, NOT the web root
/var/www/myapp/public/ ← THIS is what Nginx should serve
Step 1: Create the Nginx Server Block
sudo nano /etc/nginx/sites-available/myapp
Here’s a complete, production-ready configuration:
server {
listen 80;
server_name myapp.example.com;
root /var/www/myapp/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 ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-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;
}
client_max_body_size 20M;
}
Let me walk through the parts that matter most:
root /var/www/myapp/public;— as discussed, this is non-negotiable for security.try_files $uri $uri/ /index.php?$query_string;— this implements Laravel’s “pretty URL” routing. If a requested file doesn’t physically exist, the request gets routed throughindex.php, which lets Laravel’s router take over.fastcgi_pass unix:/run/php/php8.2-fpm.sock;— this is the socket path PHP-FPM listens on. Confirm yours matches by checking/etc/php/8.2/fpm/pool.d/www.conffor thelistendirective.location ~ /\.(?!well-known).* { deny all; }— this blocks access to all dotfiles (like.envand.git) except the.well-knowndirectory, which is needed for things like Let’s Encrypt’s ACME challenge and Apple Pay domain verification.fastcgi_hide_header X-Powered-By;— a small hardening step that avoids leaking your PHP version in response headers.
Step 2: Enable the Site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 3: Set Correct Permissions
Laravel needs to write to storage and bootstrap/cache. I set ownership to the web server user (www-data on Debian/Ubuntu) and restrict permissions appropriately:
sudo chown -R www-data:www-data /var/www/myapp
sudo find /var/www/myapp -type f -exec chmod 644 {} \;
sudo find /var/www/myapp -type d -exec chmod 755 {} \;
sudo chmod -R ug+rwx /var/www/myapp/storage /var/www/myapp/bootstrap/cache
Step 4: Configure Your .env for Production
Before going live, double-check these in your .env:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://myapp.example.com
APP_DEBUG=false is critical — leaving debug mode on in production means detailed stack traces (including file paths and environment variables) get shown to anyone who triggers an error. I’ve seen this leak database credentials on more than one audit.
Then cache Laravel’s configuration for a performance boost:
php artisan config:cache
php artisan route:cache
php artisan view:cache
Adding HTTPS
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d myapp.example.com
Certbot updates your server block automatically, adding a 443 listener and redirecting HTTP to HTTPS. Once that’s done, update APP_URL in .env to use https:// and re-run php artisan config:cache.
Testing Your Setup
sudo nginx -t— validate syntaxcurl -I http://myapp.example.com— confirm a response, ideally a redirect to HTTPS if Certbot is configured- Visit the site in a browser and confirm the homepage loads
- Test a route that hits the database to confirm PHP-FPM and your DB connection both work
- Try directly requesting
/.envin a browser — you should get a 403 or 404, never the actual file contents - Check
php artisan route:listmatches what you see working in the browser
Troubleshooting Common Issues
502 Bad Gateway — Usually means PHP-FPM isn’t running, or the socket path in your Nginx config doesn’t match the actual PHP-FPM socket. Run sudo systemctl status php8.2-fpm and check ls -l /run/php/.
404 on every route except the homepage — This almost always means the try_files directive is missing or misconfigured, so Laravel’s router never gets a chance to handle the request.
“The stream or file … could not be opened” in Laravel logs — A permissions issue on storage/logs. Re-run the chown/chmod commands above.
White screen with no error — Check storage/logs/laravel.log first, then Nginx’s error log at /var/log/nginx/error.log. If both are empty, temporarily set APP_DEBUG=true (only on a non-public staging environment!) to see the actual error, then set it back to false.
Uploaded files failing — Increase client_max_body_size in Nginx and upload_max_filesize/post_max_size in php.ini.
Security Considerations
- Never expose the project root — only
public/ - Block dotfile access as shown above
- Keep
APP_DEBUG=falsein production, always - Rotate your
APP_KEYonly when you understand the consequences (it invalidates existing encrypted data and sessions) - Run
composer install --no-devin production to avoid shipping dev dependencies - Set up a firewall (
ufw) to only expose ports 80/443 and your SSH port - Consider fail2ban for repeated failed login attempts on Laravel’s auth routes
Performance Tips
- Enable OPcache in
php.ini— this alone can meaningfully speed up PHP execution by caching compiled bytecode - Use
php artisan config:cache,route:cache, andview:cachein every deployment — skipping this is one of the most common reasons Laravel apps feel slow in production - Enable gzip in Nginx for text-based responses
- Use a queue worker (Laravel Horizon or plain
queue:workwith Supervisor) for anything slow — sending emails, processing uploads — instead of doing it inline during the request - Tune PHP-FPM’s process manager — for most small-to-medium apps,
pm = dynamicwith sensiblepm.max_childrenbased on available RAM works well; for high-traffic apps, benchmark and adjust
Real-World Use Case
I’ve deployed this exact pattern for a SaaS product doing several hundred requests per second at peak: Nginx serving static assets directly, PHP-FPM tuned with pm.max_children sized to available memory, Laravel’s config/route/view caches warmed on every deploy, and Redis handling both the cache and queue drivers. The whole stack ran comfortably on a modest VPS because Nginx and PHP-FPM are both efficient by default — the config above is genuinely close to what I use in production, not a simplified teaching example.
Best Practices Recap
- Document root is always
public/, never the project root - Block
.env,.git, and other dotfiles explicitly APP_DEBUG=falsein production, no exceptions- Cache config, routes, and views on every deploy
- Correct ownership and permissions on
storageandbootstrap/cache - HTTPS via Let’s Encrypt, with
APP_URLmatching - Queue anything slow instead of blocking the request cycle
Getting Laravel and Nginx working together really comes down to respecting that document root boundary and getting the try_files directive right. Once those two things click, everything else — caching, HTTPS, performance tuning — is just refinement on top of a solid foundation.