The document root is the first decision
Laravel\u2019s public directory must be the web root. Serving the project root exposes .env, composer files, logs and source code to anyone who requests them directly.
This is the single most damaging misconfiguration in Laravel deployments, and it is invisible until someone tries to fetch /../.env or a stray /public path behaves unexpectedly.
server {
listen 80;
server_name example.com;
root /var/www/example.com/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
Environment configuration
The server .env is not the local .env. Copy the example, set production values, and confirm APP_DEBUG is false and APP_ENV is production before the site goes live. A true APP_DEBUG on production prints stack traces, configuration and credentials to visitors.
- APP_ENV=production
- APP_DEBUG=false
- APP_URL=https://example.com
- Session and cache drivers appropriate for a single server or a shared cache
- Database credentials restricted to the application user
Permissions: the minimum that works
The web server user needs write access to storage and bootstrap/cache and nothing else. Giving it ownership of the whole project means a compromised upload could modify application code.
sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R ug+rwX storage bootstrap/cache
sudo chmod -R go-w .
Queues, workers and the scheduler
Three Laravel features stop working the moment a deployment script does not account for them: queued jobs, the task scheduler and cache warming. All three run outside the HTTP request cycle.
# Queue worker under systemd, restarted on failure
sudo systemctl enable --now example-worker
# Scheduler: one cron entry, not one per task
* * * * * cd /var/www/example.com && php artisan schedule:run >> /dev/null 2>&1
Caching and optimisation after each deploy
Laravel can cache config, routes and views for a meaningful performance gain, but only if it is rebuilt after the code changes. Stale caches are a classic source of "I deployed but nothing changed".
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
File storage and public assets
If the application stores uploads, decide early whether storage/app/public is symlinked and served by the web server or handed off to a CDN. Symlinks that are missing after a fresh deploy produce 404s on images that exist on disk.
Uploaded files must never be executed. Configure the server to treat the storage directory as static content only.
SSL and renewal
Issue certificates with certbot and confirm renewal is scheduled and tested. An expired certificate does not merely warn users — it breaks API clients, webhooks and app backends that will not accept the connection.
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
Post-deploy verification
A deployment is not finished when the command succeeds. These checks take a minute and catch most regressions.
- Homepage and one authenticated page return HTTP 200
- Logs show no new errors after traffic resumes (storage/logs/laravel.log)
- A queued job actually completes
- The scheduler ran (check the scheduler log or a side effect)
- Migrations applied cleanly and match the code version
- SSL valid on both apex and www
- robots.txt and sitemap.xml reachable over HTTPS
- Queued worker restarted and running the new code
Deploy in a way you can reverse
Keep the previous release on disk or in a tagged revision so a bad deploy can be reverted quickly. Pair that with database backups taken before migrations run, not after. The goal is that any deploy is reversible within minutes without improvising.