Deploying Laravel applications shouldn't cause downtime. Users don't need to see "503 Service Unavailable" every time you push updates. This guide shows you how to achieve zero-downtime deployments on your VPS using battle-tested techniques.
The Problem with Traditional Deployments
Most developers start with simple deployments:
git pull origin main
composer install --no-dev
php artisan migrate --force
php artisan optimize:clear
The issue: During composer install and migrations, your application is in an inconsistent state. Users encounter errors, missing files, or half-updated code.
Common Deployment Issues
- Missing autoloader: Composer removes old files before installing new ones
- Mixed versions: Old code tries to use new database schema
- Queue workers: Running old code after deployment
- Cached routes/config: Stale cache causes 500 errors
The Solution: Atomic Deployments
Zero-downtime deployments use a symlink strategy:
- Deploy new code to a separate directory
- Run composer, migrations, cache warming in isolation
- Atomically switch symlink to new release
- Reload PHP-FPM/workers gracefully
- Keep old releases for instant rollback
Directory Structure
/var/www/myapp/
├── current → releases/20250117103045
├── releases/
│ ├── 20250117103045/ (latest)
│ ├── 20250117092130/
│ └── 20250117081522/
├── shared/
│ ├── storage/
│ │ ├── app/
│ │ ├── framework/
│ │ └── logs/
│ └── .env
└── repo/ (bare git repository)
The current symlink always points to the active release. Nginx serves from /var/www/myapp/current/public.
Prerequisites
1. VPS Setup
You'll need a VPS with:
- Ubuntu 22.04 or 24.04
- Nginx + PHP 8.3+ (PHP-FPM)
- Composer installed globally
- MySQL/PostgreSQL database
- Node.js for asset compilation (optional)
Quick setup with DanubeData VPS: Deploy a 2-core, 4GB VPS in Frankfurt with pre-installed LEMP stack in under 60 seconds.
2. SSH Key Authentication
# On your local machine
ssh-keygen -t ed25519 -C "deploy@myapp.com"
# Copy to VPS
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@your-vps-ip
3. Application User
# Create dedicated user for deployments
sudo adduser --disabled-password deployer
sudo usermod -aG www-data deployer
# Switch to deployer
sudo su - deployer
Step-by-Step Deployment Setup
Step 1: Initialize Directory Structure
#!/bin/bash
# Run this once on your VPS as deployer user
APP_DIR="/var/www/myapp"
GIT_REPO="git@github.com:yourname/yourapp.git"
# Create directories
mkdir -p $APP_DIR/{releases,shared/storage/{app,framework,logs}}
mkdir -p $APP_DIR/shared/storage/framework/{cache,sessions,views}
# Clone repository
git clone --bare $GIT_REPO $APP_DIR/repo
# Create shared .env
nano $APP_DIR/shared/.env
# Add your production environment variables
# Set permissions
chmod -R 775 $APP_DIR/shared/storage
chmod 640 $APP_DIR/shared/.env
Step 2: Create Deployment Script
Save this as deploy.sh on your VPS:
#!/bin/bash
set -e
# Configuration
APP_DIR="/var/www/myapp"
REPO_DIR="$APP_DIR/repo"
RELEASES_DIR="$APP_DIR/releases"
SHARED_DIR="$APP_DIR/shared"
KEEP_RELEASES=5
# Generate release name (timestamp)
RELEASE=$(date +%Y%m%d%H%M%S)
RELEASE_DIR="$RELEASES_DIR/$RELEASE"
echo "🚀 Starting deployment: $RELEASE"
# 1. Fetch latest code
echo "📥 Fetching latest code..."
cd $REPO_DIR
git fetch origin main
# 2. Create new release directory
echo "📁 Creating release directory..."
mkdir -p $RELEASE_DIR
git --work-tree=$RELEASE_DIR --git-dir=$REPO_DIR checkout -f main
# 3. Link shared files
echo "🔗 Linking shared files..."
rm -rf $RELEASE_DIR/storage
ln -s $SHARED_DIR/storage $RELEASE_DIR/storage
ln -s $SHARED_DIR/.env $RELEASE_DIR/.env
# 4. Install dependencies
echo "📦 Installing Composer dependencies..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --no-interaction
# 5. Compile assets (if needed)
if [ -f "package.json" ]; then
echo "🎨 Building frontend assets..."
npm ci --production
npm run build
fi
# 6. Optimize Laravel
echo "⚡ Optimizing Laravel..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 7. Run migrations
echo "🗄️ Running database migrations..."
php artisan migrate --force --no-interaction
# 8. Atomic switch - this is the zero-downtime moment
echo "🔄 Switching to new release..."
ln -sfn $RELEASE_DIR $APP_DIR/current
# 9. Reload PHP-FPM
echo "♻️ Reloading PHP-FPM..."
sudo systemctl reload php8.3-fpm
# 10. Restart queue workers
echo "👷 Restarting queue workers..."
php artisan queue:restart
# 11. Cleanup old releases
echo "🧹 Cleaning up old releases..."
cd $RELEASES_DIR
ls -t | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf
echo "✅ Deployment complete: $RELEASE"
echo "📊 Active release: $(readlink $APP_DIR/current)"
Make it executable:
chmod +x deploy.sh
Step 3: Configure Nginx
server {
listen 80;
server_name myapp.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 = /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 ~ .php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /.(?!well-known).* {
deny all;
}
}
# Reload Nginx
sudo nginx -t && sudo systemctl reload nginx
Step 4: Setup Queue Workers with Supervisor
# Install Supervisor
sudo apt install supervisor
# Create worker configuration
sudo nano /etc/supervisor/conf.d/myapp-worker.conf
Add this configuration:
[program:myapp-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/myapp/current/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deployer
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/myapp/shared/storage/logs/worker.log
stopwaitsecs=3600
# Start workers
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start myapp-worker:*
Deploy Your Application
From your VPS:
cd /var/www/myapp
./deploy.sh
Output:
🚀 Starting deployment: 20250117103045
📥 Fetching latest code...
📁 Creating release directory...
🔗 Linking shared files...
📦 Installing Composer dependencies...
⚡ Optimizing Laravel...
🗄️ Running database migrations...
🔄 Switching to new release...
♻️ Reloading PHP-FPM...
👷 Restarting queue workers...
🧹 Cleaning up old releases...
✅ Deployment complete: 20250117103045
Zero downtime achieved! The symlink switch is atomic—one moment users see the old code, the next moment they see the new code.
Advanced: CI/CD Integration
GitHub Actions Deployment
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: deployer
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
./deploy.sh
Set secrets in GitHub: Settings → Secrets → Actions
Rollback Strategy
If something goes wrong, rollback is instant:
#!/bin/bash
# rollback.sh
APP_DIR="/var/www/myapp"
CURRENT_RELEASE=$(readlink $APP_DIR/current)
PREVIOUS_RELEASE=$(ls -t $APP_DIR/releases | sed -n 2p)
echo "🔙 Rolling back from $CURRENT_RELEASE to $PREVIOUS_RELEASE"
ln -sfn $APP_DIR/releases/$PREVIOUS_RELEASE $APP_DIR/current
sudo systemctl reload php8.3-fpm
php artisan queue:restart
echo "✅ Rollback complete"
Production Checklist
Environment (.env)
APP_ENV=production
APP_DEBUG=false
APP_URL=https://myapp.com
# Use strong random key
APP_KEY=base64:generated_key_here
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=myapp_production
DB_USERNAME=myapp_user
DB_PASSWORD=strong_password
# Queue
QUEUE_CONNECTION=database
# Caching
CACHE_DRIVER=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
Security Hardening
- Disable directory listing: Already handled by Laravel
- Hide PHP version:
expose_php = Offin php.ini - Enable HTTPS: Use Let's Encrypt (certbot)
- Rate limiting: Enable in
app/Http/Kernel.php - CSRF protection: Enabled by default in Laravel
Performance Optimization
# Enable OPcache
sudo nano /etc/php/8.3/fpm/php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0 # Production only
# Restart PHP-FPM
sudo systemctl restart php8.3-fpm
Monitoring Your Application
Laravel Horizon (for Redis Queues)
composer require laravel/horizon
php artisan horizon:install
php artisan horizon:publish
# Update Supervisor config to use Horizon
command=/usr/bin/php /var/www/myapp/current/artisan horizon
Application Logs
# View real-time logs
tail -f /var/www/myapp/shared/storage/logs/laravel.log
# Monitor queue workers
sudo supervisorctl status myapp-worker:*
# Check PHP-FPM status
sudo systemctl status php8.3-fpm
Common Deployment Issues
| Issue | Cause | Solution |
|---|---|---|
| 500 Error after deploy | Cached config/routes | Clear cache: php artisan optimize:clear |
| Queue jobs not processing | Workers not restarted | Run: php artisan queue:restart |
| Permission denied | Wrong ownership | chown -R deployer:www-data storage |
| Symlink not updating | ln without -f flag | Use ln -sfn for force + no-dereference |
| Mix manifest error | Assets not compiled | Run npm run build before deploy |
Why Choose DanubeData for Laravel Hosting?
| Feature | DanubeData | DigitalOcean | AWS Lightsail |
|---|---|---|---|
| 2-core, 4GB VPS | $8.99/mo | $24/mo | $20/mo |
| NVMe Storage | ✓ Included | ✓ Included | SSD only |
| Managed Database | +$3.99/mo | +$15/mo | Separate service |
| Redis/Cache | +$2.99/mo | +$15/mo | Not available |
| EU Data Residency | ✓ Germany | Multiple regions | Multiple regions |
| Setup Time | < 60 seconds | ~2 minutes | ~3 minutes |
Complete Laravel Stack on DanubeData
For a production Laravel application, you need:
- VPS (2-core, 4GB): $8.99/month - Run your application
- Managed MySQL: $3.99/month - Database with automated backups
- Redis Cache: $2.99/month - Session storage and caching
- S3 Storage: $3.99/month - File uploads and static assets
Total: $19.96/month for a complete, production-ready Laravel stack with zero-downtime deployments.
Next Steps
- Create your DanubeData account
- Deploy a VPS with Ubuntu + LEMP stack
- Follow this guide to setup zero-downtime deployments
- Add managed database and Redis for optimal performance
- Configure SSL with Let's Encrypt
- Setup monitoring and backups
Questions about Laravel hosting? Contact our team or check our documentation for more deployment guides.