Laravel applications run well on a plain Linux server: Nginx serves static files and passes requests to PHP-FPM, a database stores the data, and a couple of background processes handle queued jobs and scheduled tasks. In this tutorial you will deploy an existing Laravel application from a Git repository to Ubuntu 24.04 with Nginx, PHP 8.3-FPM and MySQL 8.0, run the queue worker as a systemd service, schedule tasks with cron, secure the site with Let's Encrypt, and set up a repeatable update procedure.

Prerequisites

To follow this tutorial, you will need:

  • A server running Ubuntu 24.04 LTS with at least 1 GB of RAM, for example a CubePath VPS, and a non-root user with sudo privileges (your_user in this guide).
  • A domain name with an A record for your_domain pointing to your server's IP address.
  • A Laravel 11 or later application in a Git repository the server can clone, for example over HTTPS with an access token or over SSH with a deploy key.

Current Laravel releases need PHP 8.2 or 8.3, and Ubuntu 24.04 ships PHP 8.3, so no extra repositories are needed. Throughout this guide, the application lives in /var/www/your_app.

Step 1 - Installing Nginx, PHP and MySQL

Install Nginx, PHP-FPM, the PHP extensions Laravel and its common packages need, MySQL, and the tools Composer uses:

sudo apt update
sudo apt install nginx mysql-server 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 git unzip

Check the PHP version:

php -v
PHP 8.3.6 (cli) (built: ...) (NTS)

Allow web traffic through the firewall, keeping SSH open:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Step 2 - Installing Composer

Install Composer from the official installer, verifying its checksum against the signature published by the Composer project:

EXPECTED_CHECKSUM="$(curl -fsSL https://composer.github.io/installer.sig)"
curl -fsSL https://getcomposer.org/installer -o composer-setup.php
echo "${EXPECTED_CHECKSUM}  composer-setup.php" | sha384sum -c -
composer-setup.php: OK

If the check does not print OK, delete the file and try again. Install Composer globally and remove the installer:

sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
composer --version

Step 3 - Creating the database

Open the MySQL shell as root. On Ubuntu, the root account uses socket authentication, so no password is needed with sudo:

sudo mysql

Create a database and a dedicated user. Replace your_strong_password with a strong password of your own:

CREATE DATABASE your_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'your_app'@'localhost' IDENTIFIED BY 'your_strong_password';
GRANT ALL PRIVILEGES ON your_app.* TO 'your_app'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Check that the new user can log in:

mysql -u your_app -p -e "SHOW DATABASES;"

The output should list your_app.

Step 4 - Creating a PHP-FPM pool for the application

By default, PHP-FPM runs everything as www-data, while you run Artisan and Composer as your own user. That split causes the most common Laravel deployment error: log or cache files created by one user that the other cannot write. A dedicated pool that runs as your_user avoids it, and keeps the application isolated from other sites on the server.

Create the pool file:

sudo nano /etc/php/8.3/fpm/pool.d/your_app.conf
[your_app]
user = your_user
group = your_user

listen = /run/php/your_app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500

Nginx connects to the socket as www-data, which is why the socket belongs to that user. Test the configuration and restart PHP-FPM:

sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm
ls -l /run/php/your_app.sock
srw-rw---- 1 www-data www-data 0 Sep 25 10:00 /run/php/your_app.sock

Step 5 - Deploying the application code

Create the application directory, owned by your_user:

sudo mkdir -p /var/www/your_app
sudo chown your_user:your_user /var/www/your_app

Clone the repository and install the PHP dependencies. --no-dev skips development packages such as test tools, and --optimize-autoloader builds a faster class map:

git clone https://github.com/your_org/your_app.git /var/www/your_app
cd /var/www/your_app
composer install --no-dev --optimize-autoloader

Nginx runs as www-data and only needs to read the files in public/. Files cloned with the default umask are readable by everyone, so no permission changes are needed.

If your application builds front-end assets with Vite, build them now (this needs Node.js on the server) or build them in CI and commit or upload the public/build directory:

npm ci
npm run build

Step 6 - Configuring the environment

Laravel reads its configuration from the .env file, which is never committed to Git. Create it from the example and generate the application key:

cp .env.example .env
php artisan key:generate
   INFO  Application key set successfully.

Open the file:

nano /var/www/your_app/.env

Set the production values. Laravel 11 and later default to SQLite in .env.example, so switch the connection to MySQL:

APP_NAME="Your App"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your_domain

LOG_CHANNEL=daily
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_app
DB_USERNAME=your_app
DB_PASSWORD=your_strong_password

SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database

APP_DEBUG=false is essential: with debug enabled, error pages expose environment variables, including the database password. The database drivers for sessions, cache and queues work out of the box; you can move them to Redis later.

Make the file readable only by its owner:

chmod 600 .env

Run the migrations. --force is required because Laravel asks for confirmation in production:

php artisan migrate --force
   INFO  Running migrations.

  0001_01_01_000000_create_users_table ................... 12.51ms DONE
  0001_01_01_000001_create_cache_table .................... 5.03ms DONE
  0001_01_01_000002_create_jobs_table ..................... 9.87ms DONE

Create the public/storage link for user uploads and cache the configuration, routes, views and events:

php artisan storage:link
php artisan optimize

After optimize, Laravel no longer reads .env on each request. Whenever you change .env, run php artisan optimize again.

Step 7 - Configuring Nginx

Create a server block based on the configuration recommended in the Laravel documentation. The document root is the public directory, so the rest of the code, including .env, is never reachable over HTTP:

sudo nano /etc/nginx/sites-available/your_app
server {
    listen 80;
    listen [::]:80;
    server_name your_domain;
    root /var/www/your_app/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;
    client_max_body_size 20m;

    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 ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/your_app.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Only index.php is ever executed; any other .php file under public is not passed to PHP. Enable the site, disable the default one, and reload:

sudo ln -s /etc/nginx/sites-available/your_app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Test the application:

curl -sI http://your_domain/ | head -n 1
HTTP/1.1 200 OK

If you get a 500 error instead, check storage/logs/ for the Laravel error message.

Step 8 - Enabling HTTPS

Install Certbot and request a certificate. The Nginx plugin adds the certificate to the server block and redirects HTTP to HTTPS:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain

Confirm HTTPS works and that renewal is set up:

curl -sI https://your_domain/ | head -n 1
sudo certbot renew --dry-run

Step 9 - Running the queue worker with systemd

Queued jobs (emails, notifications, exports) are processed by a long-running queue:work process. Run it as a systemd service so it starts at boot and restarts if it exits:

sudo nano /etc/systemd/system/your_app-queue.service
[Unit]
Description=Laravel queue worker for your_app
After=network.target mysql.service

[Service]
User=your_user
Group=your_user
WorkingDirectory=/var/www/your_app
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

--max-time=3600 makes the worker exit cleanly every hour, and systemd starts a fresh one, which releases memory held by long-running PHP. Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now your_app-queue
systemctl status your_app-queue --no-pager
● your_app-queue.service - Laravel queue worker for your_app
     Loaded: loaded (/etc/systemd/system/your_app-queue.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-25 10:12:03 UTC; 4s ago

Follow the worker's output with:

journalctl -u your_app-queue -f

For more throughput, turn the unit into a template ([email protected]) and start several instances, such as your_app-queue@1 and your_app-queue@2.

Step 10 - Scheduling tasks

Laravel's scheduler needs a single cron entry that runs every minute; Laravel decides which of your scheduled tasks are due. Edit the crontab of your_user:

crontab -e

Add this line:

* * * * * cd /var/www/your_app && php artisan schedule:run >> /dev/null 2>&1

List the tasks Laravel will run and when:

php artisan schedule:list

Step 11 - Deploying updates

Each update follows the same sequence: enable maintenance mode, pull the code, install dependencies, migrate, rebuild caches, and restart the processes that keep old code in memory. Run these commands from /var/www/your_app:

php artisan down
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize
php artisan queue:restart
sudo systemctl reload php8.3-fpm
php artisan up

queue:restart tells the worker to exit after its current job; systemd then starts it with the new code. Reloading PHP-FPM clears OPcache so the web requests also run the new code. If you run these steps often, put them in a script in the repository so every deploy is identical.

Troubleshooting

  • 500 error with "Permission denied" in storage/logs: some files were created by another user, for example by running Artisan with sudo. Fix ownership with sudo chown -R your_user:your_user /var/www/your_app/storage /var/www/your_app/bootstrap/cache.
  • 502 Bad Gateway: Nginx cannot reach the PHP-FPM socket. Check that /run/php/your_app.sock exists and that the fastcgi_pass path matches it, then look at sudo journalctl -u php8.3-fpm -n 50.
  • Changes to .env have no effect: the configuration is cached. Run php artisan optimize (or php artisan config:clear to stop caching).
  • Jobs stay in the jobs table: the worker is not running or is failing. Check systemctl status your_app-queue and php artisan queue:failed.

Conclusion

Your Laravel application now runs on Ubuntu 24.04 behind Nginx with its own PHP-FPM pool, a MySQL database, HTTPS, a systemd-managed queue worker and the scheduler. Next, set up automated database backups, move cache, sessions and queues to Redis if traffic grows, and automate the update sequence from Step 11 in your CI pipeline.