Deploying Laravel to an Ubuntu server, production-ready
Nginx, PHP-FPM, MySQL or Postgres, Composer, permissions, .env, SSL, queue workers and the scheduler: a production Laravel server, step by step.
Jacob Søndergaard ·
Deploying Laravel to a VPS on Ubuntu comes down to a short list: PHP-FPM in the version your app needs, Nginx in front of it, a database and a user, a Unix account for the site, a production .env, a certificate, and something running the queue and the scheduler. This post builds that list in order on Ubuntu 26.04 or 24.04 and ends with a Laravel 13 application answering over HTTPS. The narrower posts it links to go deeper into deploys, workers, the scheduler and the Nginx file.
It was built end to end twice: on 26.04 with Ubuntu’s PHP 8.5 and MySQL, and on 24.04 with PHP 8.5 from the ondrej/php PPA and PostgreSQL. The paths and names, /var/www/example.com and a site account called example, are the same in every post in the series.
Before you start
- A server with a sudo user, SSH keys only and UFW open on 22, 80 and 443. The hardening post sets that up in the right order.
- An A record for
example.comandwww.example.compointing at the server, so the certificate step works when you reach it. - Your application in a Git repository, with
composer.lockand, if it builds assets,package-lock.jsoncommitted.npm cirefuses to run without the lock file.
Which PHP Ubuntu ships, and when you need a PPA
Each Ubuntu release carries exactly one PHP in its own archive: PHP 8.5 on 26.04 and PHP 8.3 on 24.04. Laravel 13 requires PHP 8.3 or newer, so both releases run current Laravel with no extra package source. Use the archive version unless your application needs another one.
For a different version on 24.04, add Ondřej Surý’s PPA, which carries every supported PHP for that release:
sudo add-apt-repository ppa:ondrej/php
sudo apt install nginx php8.5-fpm php8.5-cli php8.5-mbstring php8.5-xml php8.5-curl php8.5-zip php8.5-intl php8.5-bcmath php8.5-mysql composer git unzipOn 26.04 the PPA has no packages. Its description, which add-apt-repository prints, says so and points to the same maintainer’s repository at packages.sury.org, the source for every other PHP version on 26.04:
sudo curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb
sudo dpkg -i /tmp/debsuryorg-archive-keyring.deb
echo "deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/php.list
sudo apt updateEither source then supplies newer builds of every php8.x package, including the version you already had from Ubuntu: on the 24.04 test server, adding the PPA moved PHP 8.3’s candidate from Ubuntu’s 8.3.6 to the PPA’s 8.3.35. That is usually what you want, but your PHP updates now come from one maintainer rather than from Ubuntu’s security team.
They also arrive only if something installs them. Ubuntu’s unattended-upgrades trusts its own security pockets and nothing else, so a PHP from either source is never patched on its own. When PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 fixed two remote code execution flaws on 21 October 2026, both sources published builds within hours, and a server with the default configuration installed none of them. Add both origins, then check that the last line lists them:
sudo tee /etc/apt/apt.conf.d/52php-source-upgrades >/dev/null <<'EOF'
Unattended-Upgrade::Origins-Pattern {
"origin=LP-PPA-ondrej-php";
"origin=deb.sury.org";
};
EOF
sudo unattended-upgrade --dry-run --debug 2>&1 | grep "Allowed origins"Install Nginx, PHP-FPM, Composer and Node
sudo apt update
sudo apt install nginx php8.5-fpm php8.5-cli php8.5-mbstring php8.5-xml php8.5-curl php8.5-zip php8.5-intl php8.5-bcmath php8.5-mysql composer git unzip nodejs npm
php -v
node -vThese are the extensions a new Laravel app needs; add php8.5-redis or php8.5-gd if yours uses them. OPcache is built into PHP 8.5. Node is there for npm run build: a new Laravel app builds its assets with Vite 8, which needs Node 20.19 or newer, and 26.04’s nodejs is Node 22.
On 24.04, write 8.3 wherever the commands say 8.5 and leave nodejs npm off the line: 24.04’s Node is 18, too old for Vite 8. Install Node 22 from NodeSource instead, or build assets in CI and deploy them:
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt install nodejs
node -vThe Node snap is not an alternative here. Snaps refuse to run for an account whose home directory is outside /home, and the site account below lives in /var/www. On 24.04 with a newer PHP from the PPA, Ubuntu’s Composer 2.7 prints a screen of deprecation notices on every run; they are noise, and Composer still installs correctly.
A database and a user
For MySQL:
sudo apt install mysql-server
DB_PASSWORD=$(openssl rand -hex 24)
sudo mysql <<SQL
CREATE DATABASE example CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'example'@'localhost' IDENTIFIED BY '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON example.* TO 'example'@'localhost';
SQL
echo "$DB_PASSWORD"For PostgreSQL:
sudo apt install postgresql php8.5-pgsql
DB_PASSWORD=$(openssl rand -hex 24)
sudo -u postgres psql <<SQL
CREATE ROLE example LOGIN PASSWORD '$DB_PASSWORD';
CREATE DATABASE example OWNER example;
SQL
echo "$DB_PASSWORD"Both engines from Ubuntu’s archive listen on localhost only, which is right for a database on the same server. The MySQL user’s grant covers its own database only. Keep the printed password for the .env step. The database owned by its role is what keeps a later PostgreSQL restore simple, as the backup post explains.
A site account, and why permissions stop being a problem
# The site's own account. Its home directory is the site.
sudo useradd --create-home --home-dir /var/www/example.com --shell /bin/bash example
# Nginx runs as www-data and has to enter the directory to serve files.
sudo chgrp www-data /var/www/example.com
sudo chmod 750 /var/www/example.com
sudo -u example mkdir -p /var/www/example.com/releases \
/var/www/example.com/shared/storage/{app/public,framework/{cache/data,sessions,views},logs}Most Laravel permission advice involves chown -R www-data or chmod -R 775 storage. Neither is needed here. One account, example, owns the code, the storage directory and the environment file, and the same account runs PHP-FPM, the queue workers and the scheduler. Everything that writes to storage and bootstrap/cache is already the owner.
Nginx runs as www-data and only reads. The site directory is mode 750 with its group set to www-data, so Nginx can enter it to serve public/ and other accounts on the server cannot. Run every artisan command as the site account, sudo -u example php artisan .... A command run as root writes root-owned files into storage, and the site then fails to write its own log; the scheduler post shows that error and the fix.
The .env file and APP_KEY
sudo -u example tee /var/www/example.com/shared/.env > /dev/null <<EOF
APP_NAME=Example
APP_ENV=production
APP_KEY=base64:$(openssl rand -base64 32)
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=example
DB_USERNAME=example
DB_PASSWORD=change-me
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
EOF
sudo chmod 600 /var/www/example.com/shared/.envThen open the file and replace change-me with the database password. For PostgreSQL, also set DB_CONNECTION=pgsql and DB_PORT=5432.
sudo -u example nano /var/www/example.com/shared/.envAPP_KEY encrypts sessions, cookies and anything your models store with encrypted casts. Generate it once per environment and keep it: a new key logs every user out and makes previously encrypted data unreadable. The heredoc above makes one with openssl, the same 32 random bytes php artisan key:generate would write. APP_DEBUG=false is not optional on a public server: with it on, error pages can expose configuration values to whoever triggered them.
The file is mode 600, readable by the site account only. The database, cache, queue and session drivers all start on the database, which needs nothing else installed; switch them to Redis later if the load calls for it.
A PHP-FPM pool per site
sudo tee /etc/php/8.5/fpm/pool.d/example.com.conf > /dev/null <<'EOF'
[example.com]
user = example
group = example
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
EOF
sudo php-fpm8.5 -t
sudo systemctl reload php8.5-fpmUbuntu’s default www pool runs as www-data and is shared by everything. A pool of the site’s own runs its PHP as example, on its own socket, so a second site on the server cannot read this one’s files, and a runaway request in one site cannot use up another’s workers. pm.max_children caps memory: each worker holds a whole request, so divide the memory you can give PHP by the size of a busy worker, measured with ps -o rss= -C php-fpm8.5.
Two more settings make deploys clean. The first lets a reload wait for running requests:
sudo sed -i 's/^;process_control_timeout = 0/process_control_timeout = 30s/' /etc/php/8.5/fpm/php-fpm.conf
sudo php-fpm8.5 -t
sudo systemctl reload php8.5-fpmThe second lets the site account reload PHP-FPM, and nothing else, when it deploys:
echo 'example ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.5-fpm' > /tmp/example.sudoers
sudo visudo -cf /tmp/example.sudoers
sudo install -m 0440 /tmp/example.sudoers /etc/sudoers.d/exampleWithout the first, every reload cuts off the requests in flight with a 502; the zero-downtime post measured it.
The Nginx server block
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/current/public;
index index.php;
charset utf-8;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 20m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
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 ^~ /build/assets/ {
expires max;
access_log off;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/example.com.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_hide_header X-Powered-By;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}sudo nano /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo nginx -t
sudo systemctl reload nginxThe root is current/public, so only the public directory is reachable, and the last location refuses every dotfile, .env included. The Nginx post goes through every line, including what to change for Reverb and for an app in a subdirectory.
The first deploy
The site deploys itself from Git with one script: it clones a new release, links the shared .env and storage, runs Composer, builds assets, migrates, caches config and routes, switches the current symlink and checks Laravel’s /up route, rolling back if it fails. Give the site account a deploy key and add the public half to your repository as a read-only deploy key:
sudo -u example mkdir -m 700 /var/www/example.com/.ssh
sudo -u example ssh-keygen -t ed25519 -N '' -C example.com -f /var/www/example.com/.ssh/id_ed25519
sudo -u example sh -c 'ssh-keyscan github.com >> /var/www/example.com/.ssh/known_hosts'
sudo cat /var/www/example.com/.ssh/id_ed25519.pubSave the script as /var/www/example.com/deploy.sh with your own REPO and BRANCH:
#!/usr/bin/env bash
# Zero-downtime deploy: build a new release, swap the current symlink,
# health-check it and roll back if it fails. Run it as the site's user.
set -euo pipefail
DOMAIN=example.com
REPO=git@github.com:acme/example.git
BRANCH=main
PHP_VERSION=8.5
KEEP=5
BASE=/var/www/$DOMAIN
NEW=$BASE/releases/$(date -u +%Y%m%d%H%M%S)
# Every php below, Composer's included, is the version the FPM pool runs.
php() { command "php$PHP_VERSION" "$@"; }
# Point current at $1 with a single rename(2), which is atomic.
point_current() {
ln -sfn "$1" "$BASE/current.next"
mv -T "$BASE/current.next" "$BASE/current"
}
# A 2xx passes and a 4xx or 5xx fails at once. Only "no answer yet" is retried.
healthy() {
local attempt status
for attempt in 1 2 3 4 5; do
status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 --location \
--resolve "$DOMAIN:80:127.0.0.1" --resolve "$DOMAIN:443:127.0.0.1" \
"http://$DOMAIN/up") || true
case "$status" in
2??) return 0 ;;
000) sleep 2 ;;
*) echo "Health check: /up answered $status." >&2; return 1 ;;
esac
done
echo "Health check: /up did not answer." >&2
return 1
}
# One deploy at a time.
exec 9>"$BASE/deploy.lock"
flock -n 9 || { echo "Another deploy is running." >&2; exit 1; }
PREVIOUS=""
if [ -L "$BASE/current" ]; then
PREVIOUS=$(readlink -f "$BASE/current")
fi
# A release that never went live, or was rolled back, is removed on the way out.
LIVE_IS_NEW=0
trap '[ "$LIVE_IS_NEW" = 1 ] || rm -rf "$NEW"' EXIT
echo "Building $NEW"
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO" "$NEW"
cd "$NEW"
ln -s "$BASE/shared/.env" .env
rm -rf storage
ln -s "$BASE/shared/storage" storage
php "$(command -v composer)" install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
if [ -f package.json ]; then
npm ci --no-audit --no-fund
npm run build
fi
php artisan storage:link
php artisan migrate --force
php artisan optimize
echo "Activating $NEW"
point_current "$NEW"
LIVE_IS_NEW=1
sudo -n systemctl reload "php$PHP_VERSION-fpm"
if ! healthy; then
if [ -n "$PREVIOUS" ]; then
echo "Rolling back to $PREVIOUS" >&2
point_current "$PREVIOUS"
LIVE_IS_NEW=0
sudo -n systemctl reload "php$PHP_VERSION-fpm"
fi
exit 1
fi
# Workers finish their current job, exit, and systemd starts them on the new release.
php artisan queue:restart
# Keep the newest $KEEP releases, and never the one that is live.
LIVE=$(readlink -f "$BASE/current")
ls -1 "$BASE/releases" | sort -r | tail -n +$((KEEP + 1)) | while read -r old; do
if [ "$BASE/releases/$old" != "$LIVE" ]; then
rm -rf "${BASE:?}/releases/$old"
fi
done
echo "Live: $LIVE"sudo -u example nano /var/www/example.com/deploy.sh
sudo chmod 755 /var/www/example.com/deploy.sh
sudo -u example /var/www/example.com/deploy.shZero-downtime Laravel deploys explains each part of it and how to roll back by hand. Run the same command for every later deploy, from your machine over SSH or from CI. Then check the site:
curl -I http://example.com/up # 200
curl -I http://example.com/.env # 403SSL with certbot
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com --redirectCertbot adds the 443 listener and the certificate to the server block above and makes port 80 redirect to HTTPS. Renewal is already scheduled by the package’s timer. The deploy script’s health check follows the redirect, so deploys keep working unchanged. The certbot post covers checking renewal, HSTS and wildcard certificates.
Queue workers and the scheduler
With QUEUE_CONNECTION=database, queued jobs, mail and notifications wait in the jobs table until a worker picks them up, and nothing runs one yet. Run queue:work or Horizon as a systemd service: queue workers with systemd has the unit files. The deploy script already tells workers to restart onto each new release.
Scheduled tasks need one crontab line for the site account, running in current with the pool’s PHP:
(sudo crontab -u example -l 2>/dev/null || true; echo '* * * * * cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1') | sudo crontab -u example -
sudo crontab -u example -lThe scheduler post covers why it works by hand and not from cron when the user or the PHP binary is wrong.
When it does not work
- 502 Bad Gateway. Nginx cannot reach PHP-FPM. Its error log,
/var/log/nginx/example.com.error.log, says why:connect() to unix:/run/php/example.com.sock failed (2: No such file or directory) while connecting to upstreammeans the pool is not running or itslistenpath differs fromfastcgi_pass;(13: Permission denied)means the socket’slisten.owneris notwww-data. Check the pool withsudo php-fpm8.5 -tandsystemctl status php8.5-fpm. - The Nginx welcome page, or a 404 from Nginx. Another server block answered:
server_namedoes not match the name you requested, or the site is not linked intosites-enabled.sudo nginx -T | grep server_namelists what is loaded. - 500 with a blank page. With debug off, the reason is in
/var/www/example.com/shared/storage/logs/laravel.log. A wrong database password and a missing PHP extension are the usual two. - The deploy stops at npm.
npm: command not foundmeans Node is missing, andnpm ci can only install with an existing package-lock.jsonmeans the lock file is not committed.
How Provisore does this
Provisore builds this server for you. A Laravel site gets its own Unix account, its own PHP-FPM pool on any PHP from 8.1 to 8.5, an Nginx server block with public/ as the root, the scheduler’s cron entry, and releases deployed on every push with a health check. The PHP source is added only when a site asks for a version Ubuntu does not ship. See sites, runtimes, databases and domains, or Provisore for Laravel. Laravel’s own deployment documentation lists the server requirements this post meets.