BlogTutorialsDeploy Laravel with Zero Downtime: Complete VPS Guide

Deploy Laravel with Zero Downtime: Complete VPS Guide

Adrian Silaghi
Adrian Silaghi
December 8, 2025
10 min read
87 views
#laravel #deployment #zero-downtime #vps #devops #php

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:

  1. Deploy new code to a separate directory
  2. Run composer, migrations, cache warming in isolation
  3. Atomically switch symlink to new release
  4. Reload PHP-FPM/workers gracefully
  5. 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 = Off in 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:

  1. VPS (2-core, 4GB): $8.99/month - Run your application
  2. Managed MySQL: $3.99/month - Database with automated backups
  3. Redis Cache: $2.99/month - Session storage and caching
  4. 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

  1. Create your DanubeData account
  2. Deploy a VPS with Ubuntu + LEMP stack
  3. Follow this guide to setup zero-downtime deployments
  4. Add managed database and Redis for optimal performance
  5. Configure SSL with Let's Encrypt
  6. Setup monitoring and backups

Questions about Laravel hosting? Contact our team or check our documentation for more deployment guides.

Share this article

Ready to Get Started?

Deploy your infrastructure in minutes with DanubeData's managed services.