Zero-Downtime Laravel Deployment on Ubuntu
I configure zero-downtime Laravel deployments on Ubuntu servers using Git, PHP 8.3, and basic shell scripts without complex CI/CD tools.
On this page
Deploying a modern web application means handling file permissions, queue workers, and environment variables without dropping a single HTTP request. When I set up a Laravel deployment pipeline for client boxes running Ubuntu 24.04, I avoid heavy orchestration tools and stick to a clean symlink structure. A manual git pull on a live web root breaks sessions and throws errors while Composer downloads packages, so I always build the release in an isolated folder first.
Preparing the Server Environment
Before writing any deployment scripts, my servers need a standard stack running PHP 8.3, Nginx 1.24, and PostgreSQL or MySQL. I install PHP and the required extensions using the standard PPA on Ubuntu.
sudo apt update
sudo apt install php8.3-fpm php8.3-cli php8.3-mbstring php8.3-xml php8.3-bcmath php8.3-curl php8.3-zip php8.3-pgsql git unzip nginx
Next, I create the base directory structure for the application. Instead of pointing Nginx directly to /var/www/html/public, I use a releases directory and a current symlink. This mirrors how capistrano-style deployments work.
sudo mkdir -p /var/www/myapp/releases
sudo mkdir -p /var/www/myapp/shared
sudo chown -R $USER:$USER /var/www/myapp
Inside the shared directory, I place the production .env file and the storage folder so that user uploads and logs persist across releases. If you manage multiple projects or need a local sandbox environment while testing code, you might appreciate how tools like Runix: Local PHP Server for Android simplify offline testing, but production requires strict directory separation.
Structuring the Deployment Script
My deployment script handles checking out the repository, installing dependencies without dev packages, linking the shared resources, and running migrations. I keep this script in the root of the repository as deploy.sh.
#!/bin/bash
date=$(date +%Y%m%d%H%M%S)
release_dir="/var/www/myapp/releases/$date"
echo "Starting deployment..."
# Clone the repository
git clone --depth 1 [email protected]:username/myapp.git "$release_dir"
# Copy shared .env and link shared storage
ln -nfs /var/www/myapp/shared/.env "$release_dir/.env"
rm -rf "$release_dir/storage"
ln -nfs /var/www/myapp/shared/storage "$release_dir/storage"
# Install composer dependencies
cd "$release_dir"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
# Run migrations and cache config
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Point current symlink to the new release
ln -nfs "$release_dir" /var/www/myapp/current
# Reload PHP-FPM
sudo systemctl reload php8.3-fpm
echo "Deployment finished successfully."
I make the script executable with chmod +x deploy.sh and run it whenever I push updates. For managing other complex workflows, I sometimes look at automation patterns similar to what I use when planning schedules in RosterX: Shift Planner, but for code delivery, simple shell execution rarely fails.
Configuring Nginx and PHP-FPM
Nginx needs to point to the current/public directory. I configure the server block to handle PHP requests through the PHP-FPM socket.
server {
listen 80;
server_name example.com;
root /var/www/myapp/current/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 ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
After saving this to /etc/nginx/sites-available/myapp, I enable the site and test the configuration.
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Managing Queue Workers with Supervisor
Laravel queue workers must run continuously in the background. If you restart a worker abruptly, running jobs can fail. I configure Supervisor to manage the queue daemon.
[program:myapp-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/myapp/current/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/myapp/shared/storage/logs/worker.log
stopwaitsecs=3600
To apply changes after updating code, I tell Supervisor to reread and update its processes.
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart myapp-worker:*
What went wrong (gotchas)
File permissions are the number one cause of failed Laravel deployments on Ubuntu. If www-data cannot write to the storage or bootstrap/cache directories, you will see blank 500 error pages immediately after switching the symlink. I always verify ownership recursively on the shared storage folder:
sudo chown -R www-data:www-data /var/www/myapp/shared/storage
sudo chmod -R 775 /var/www/myapp/shared/storage
Another common pitfall is forgetting to clear or rebuild configuration caches. If you modify your .env file in the shared directory but forget to run php artisan config:clear, Laravel will continue serving old configuration values from the cached file in the previous release. Always run your cache clearing and rebuilding commands as part of the atomic swap phase.
Finally, make sure you prune old releases from /var/www/myapp/releases periodically. Leaving dozens of old builds will eventually fill up your disk space, especially if vendor directories are not symlinked and get fully duplicated per release. I keep the last three releases and delete the rest.
Frequently asked questions
Why use symlinks for Laravel deployment?
Symlinks allow you to switch the active web root instantly to a newly built release directory, preventing downtime during composer installs and asset compilation.
How do I handle database migrations safely during deployment?
Run php artisan migrate --force inside your deployment script after switching the symlink or right before it, ensuring your schema matches the incoming code.
Do I need Envoy or GitHub Actions for this setup?
No. A straightforward bash script triggered via SSH or webhook is often enough for small to medium client boxes.