Deploy Laravel on Ubuntu with Nginx: Step-by-Step Guide
Deploy Laravel on Ubuntu with Nginx and PHP 8.3-FPM: server setup, permissions, MySQL, free SSL with Certbot, queues with Supervisor and zero-downtime updates.
To deploy Laravel on Ubuntu with Nginx, you need four pieces working together: PHP-FPM to run the code, Nginx to serve requests, correct file permissions, and background processes for queues and the scheduler. Get any one wrong and you see a 502, a blank page or a downloadable .env file.
Shared hosting is fine to start, but a small VPS gives you control over PHP versions, queues and cron for about the same price. These are the exact steps I follow when I set up a new Laravel 11 server on Ubuntu 24.04 LTS, from a fresh machine to a live HTTPS site, plus the fixes for the errors I have hit most often in production.
Prerequisites
- An Ubuntu 24.04 LTS VPS with at least 1 GB RAM (DigitalOcean, Hetzner, Linode, AWS Lightsail)
- A domain name with an A record pointing to the server's IP
- SSH access as a non-root user with sudo
- Your Laravel 11 project in a Git repository
- A deploy key or SSH key so the server can pull from the repository
Step 1: Secure the server first
Before installing anything, create a deploy user, disable password login over SSH and enable the firewall. It takes five minutes and stops the automated brute-force attempts that start within an hour of a server going online.
sudo adduser deploy
sudo usermod -aG sudo,www-data deploy
# in /etc/ssh/sshd_config set: PasswordAuthentication no, PermitRootLogin no
sudo systemctl restart ssh
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableStep 2: Install Nginx, PHP 8.3 and MySQL
Ubuntu 24.04 ships PHP 8.3 in its default repositories, so no extra PPA is needed. Install the extensions Laravel requires plus Redis if you use it for cache or queues.
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mysql-server unzip git supervisor redis-server
sudo apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
php8.3-xml php8.3-curl php8.3-zip php8.3-bcmath php8.3-intl php8.3-redis
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composerStep 3: Create the database
Run mysql_secure_installation, then create a dedicated database user that can only access the application's database. Never connect Laravel as root.
sudo mysql_secure_installation
sudo mysql
CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;Step 4: Get the code and set permissions
Clone into /var/www, install production dependencies and configure the environment. The web server only needs write access to storage and bootstrap/cache. Everything else should be owned by your deploy user, so a compromised PHP process cannot rewrite your code.
sudo mkdir -p /var/www/app && sudo chown deploy:www-data /var/www/app
cd /var/www/app
git clone git@github.com:team/app.git .
composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
# edit .env: APP_ENV=production, APP_DEBUG=false, APP_URL, DB_* values
php artisan migrate --force
php artisan storage:link
sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
chmod 640 .envAPP_DEBUG=false is not optional. With debug on, any exception page shows environment variables, including database passwords and API keys, to whoever triggered it.
Step 5: Laravel Nginx configuration
Create a server block in /etc/nginx/sites-available. The root must point to the public folder, never the project root, or your .env becomes downloadable. The final location block denies hidden files except .well-known, which Certbot needs.
# /etc/nginx/sites-available/app
server {
listen 80;
server_name example.com www.example.com;
root /var/www/app/public;
index index.php;
charset utf-8;
client_max_body_size 20M;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
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 ~ \.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;
}
location ~ /\.(?!well-known).* { deny all; }
}Enable the site, remove the default one and test the configuration before reloading. nginx -t catches syntax errors before they take your site down.
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxStep 6: Free HTTPS with Certbot
Certbot obtains a Let's Encrypt certificate, edits the Nginx config to redirect HTTP to HTTPS, and installs a systemd timer that renews certificates automatically. Run the dry-run once to confirm renewal works.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runStep 7: Cache config and run queues with Supervisor
Cache configuration, routes, views and events for speed. In Laravel 11, php artisan optimize runs all of them in one command. Then keep queue workers alive with Supervisor, which restarts them if they crash or the server reboots.
php artisan optimize
# /etc/supervisor/conf.d/app-worker.conf
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "app-worker:*"Step 8: Schedule tasks with cron
Laravel's scheduler needs a single cron entry that runs every minute. Add it to the www-data crontab so scheduled jobs write files with the same owner as the web server.
sudo crontab -u www-data -e
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1Step 9: A simple deploy script for updates
For every release after the first, I use a small script. Maintenance mode shows a friendly page while migrations run, and queue:restart tells workers to finish their current job and reload the new code. For true zero-downtime deploys, tools like Laravel Envoy or Deployer switch a symlink between release folders, but this script is enough for most small projects.
#!/usr/bin/env bash
set -e
cd /var/www/app
php artisan down --retry=60
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan optimize
php artisan queue:restart
sudo systemctl reload php8.3-fpm
php artisan upCommon Laravel deployment errors and fixes
- 502 Bad Gateway: PHP-FPM is not running or the socket path is wrong. Check systemctl status php8.3-fpm and match fastcgi_pass to the file in /run/php.
- 500 error with an empty page: storage or bootstrap/cache is not writable by www-data. Fix ownership and check storage/logs/laravel.log.
- The stream or file laravel.log could not be opened: the log was created by root during an artisan command. Run artisan as the deploy user, and chown the file.
- 404 on every route except the home page: try_files is missing from the location block, so Nginx never forwards to index.php.
- Old config values after editing .env: the config is cached. Run php artisan config:clear or optimize again.
- Queued jobs run old code: workers keep the app in memory. Run php artisan queue:restart after every deploy.
- 413 Request Entity Too Large on uploads: raise client_max_body_size in Nginx and upload_max_filesize and post_max_size in php.ini.
Production security checklist
- APP_ENV=production and APP_DEBUG=false
- Nginx root points to /public and hidden files are denied
- .env is not world-readable and never committed
- SSH keys only, root login disabled, UFW enabled
- A dedicated MySQL user with access to one database
- Automatic security updates with the unattended-upgrades package
- Off-server database backups, tested by actually restoring one
Conclusion
Deploying Laravel on Ubuntu with Nginx is not hard once you know which pieces matter: PHP-FPM and Nginx talking over the right socket, the document root on public, tight permissions on storage, HTTPS from Certbot, and Supervisor plus cron for background work. Write those steps into a script the first time and every new server after that takes about twenty minutes.
When the project outgrows a single VPS, the same concepts carry over to Laravel Forge, Ploi or container-based setups. You will just be automating steps you already understand.
Frequently asked questions
What permissions does Laravel need on Ubuntu?
Only storage and bootstrap/cache need to be writable by the web server user, usually www-data. Set them to 775 with group www-data and keep the rest of the code owned by your deploy user.
Why does Nginx return 502 Bad Gateway for my Laravel app?
Nginx cannot reach PHP-FPM. Make sure php8.3-fpm is running and that fastcgi_pass points to the exact socket in /run/php, then check /var/log/nginx/error.log for the specific reason.
Is Nginx or Apache better for Laravel?
Both work well. Nginx with PHP-FPM uses less memory under load and is the default on Laravel Forge, which is why I use it for most deployments.
How do I keep Laravel queue workers running after a server reboot?
Use Supervisor with autostart and autorestart enabled. It starts workers on boot, restarts them if they crash, and lets you run several processes in parallel.
Do I need Laravel Forge to deploy to a VPS?
No. Forge automates these same steps and is worth paying for if you manage many servers. For one or two projects, a manual setup and a short deploy script work fine.