Laravel is the most popular PHP framework for building web applications. But deploying it beyond shared hosting requires understanding Docker, queue workers, scheduling, and database management.
This comprehensive guide shows you how to deploy Laravel 11 to production with a complete stack: Docker, Nginx, PostgreSQL, Redis, queue workers, and automatic SSL. We'll cover both traditional PHP-FPM and high-performance Laravel Octane setups.
What You'll Build
By the end of this guide, you'll have:
- ✅ Production-ready Laravel deployment with Docker
- ✅ Choice of PHP-FPM or Laravel Octane (Swoole/RoadRunner)
- ✅ Managed PostgreSQL or MySQL database
- ✅ Redis for caching, sessions, and queues
- ✅ Automatic queue worker management with Horizon
- ✅ Task scheduling with proper cron setup
- ✅ SSL certificates with auto-renewal
- ✅ Zero-downtime deployments
Prerequisites
- A Laravel 11 application ready to deploy
- Basic familiarity with Docker and command line
- A DanubeData account (or any VPS provider)
Choosing Your Stack: FPM vs Octane
Before we start, you need to decide between PHP-FPM and Laravel Octane:
| Aspect | PHP-FPM | Octane (Swoole/RR) |
|---|---|---|
| Performance | Good (baseline) | 2-10x faster |
| Memory usage | Higher (per-request) | Lower (persistent) |
| Complexity | Simple | Moderate (memory leaks) |
| Package compatibility | 100% | ~95% (some edge cases) |
| Best for | Most apps, legacy code | High-traffic, APIs |
Our recommendation: Start with PHP-FPM. Switch to Octane when you need the performance and have tested your application thoroughly.
Step 1: Prepare Your Laravel Application
Update Configuration
# config/database.php - use environment variables
'default' => env('DB_CONNECTION', 'pgsql'),
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
Configure Redis
# config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
Install Laravel Horizon (Recommended)
composer require laravel/horizon
php artisan horizon:install
Step 2: Create Docker Configuration
Dockerfile (PHP-FPM Version)
# Dockerfile
FROM php:8.3-fpm-alpine AS base
# Install system dependencies
RUN apk add --no-cache
nginx
supervisor
libpq-dev
libzip-dev
icu-dev
oniguruma-dev
freetype-dev
libjpeg-turbo-dev
libpng-dev
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
&& docker-php-ext-install
pdo_pgsql
pdo_mysql
zip
intl
mbstring
gd
pcntl
bcmath
opcache
# Install Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS
&& pecl install redis
&& docker-php-ext-enable redis
&& apk del .build-deps
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# =====================
# Builder stage
# =====================
FROM base AS builder
WORKDIR /var/www/html
# Copy composer files first for better caching
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy application code
COPY . .
# Complete composer install
RUN composer dump-autoload --optimize --no-dev
# Build assets
RUN if [ -f package.json ]; then
apk add --no-cache nodejs npm
&& npm ci
&& npm run build
&& rm -rf node_modules;
fi
# =====================
# Production stage
# =====================
FROM base AS production
WORKDIR /var/www/html
# Copy application from builder
COPY --from=builder /var/www/html /var/www/html
# Copy configuration files
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
# Set permissions
RUN chown -R www-data:www-data /var/www/html
&& chmod -R 755 /var/www/html/storage
&& chmod -R 755 /var/www/html/bootstrap/cache
# Create required directories
RUN mkdir -p /var/log/supervisor
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Dockerfile (Octane/Swoole Version)
# Dockerfile.octane
FROM php:8.3-cli-alpine AS base
# Install system dependencies
RUN apk add --no-cache
libpq-dev
libzip-dev
icu-dev
oniguruma-dev
freetype-dev
libjpeg-turbo-dev
libpng-dev
linux-headers
$PHPIZE_DEPS
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
&& docker-php-ext-install
pdo_pgsql
pdo_mysql
zip
intl
mbstring
gd
pcntl
bcmath
opcache
sockets
# Install Swoole
RUN pecl install swoole
&& docker-php-ext-enable swoole
# Install Redis extension
RUN pecl install redis
&& docker-php-ext-enable redis
# Cleanup
RUN apk del $PHPIZE_DEPS linux-headers
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy composer files first
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy application
COPY . .
RUN composer dump-autoload --optimize --no-dev
# Build assets
RUN if [ -f package.json ]; then
apk add --no-cache nodejs npm
&& npm ci
&& npm run build
&& rm -rf node_modules;
fi
# Set permissions
RUN chown -R www-data:www-data /var/www/html
&& chmod -R 755 /var/www/html/storage
USER www-data
EXPOSE 8000
CMD ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
Nginx Configuration
# docker/nginx.conf
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
client_max_body_size 100M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ .php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
location ~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~ /.(?!well-known).* {
deny all;
}
}
}
Supervisor Configuration
# docker/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/run/supervisord.pid
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:php-fpm]
command=/usr/local/sbin/php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
command=/usr/local/bin/php /var/www/html/artisan horizon
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/horizon.log
stderr_logfile=/var/log/supervisor/horizon-error.log
stopwaitsecs=3600
[program:scheduler]
command=/bin/sh -c "while true; do /usr/local/bin/php /var/www/html/artisan schedule:run --verbose --no-interaction >> /var/log/supervisor/scheduler.log 2>&1; sleep 60; done"
user=www-data
autostart=true
autorestart=true
PHP Configuration
# docker/php.ini
[PHP]
memory_limit = 256M
max_execution_time = 300
upload_max_filesize = 100M
post_max_size = 100M
max_input_vars = 3000
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.fast_shutdown=1
Step 3: Create Docker Compose Stack
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: always
environment:
- APP_ENV=production
- APP_DEBUG=false
- APP_KEY=${APP_KEY}
- APP_URL=${APP_URL}
- DB_CONNECTION=pgsql
- DB_HOST=${DB_HOST}
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=${REDIS_HOST}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_PORT=6379
- CACHE_DRIVER=redis
- SESSION_DRIVER=redis
- QUEUE_CONNECTION=redis
- MAIL_MAILER=${MAIL_MAILER}
- MAIL_HOST=${MAIL_HOST}
- MAIL_PORT=${MAIL_PORT}
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
volumes:
- storage:/var/www/html/storage/app
- logs:/var/www/html/storage/logs
networks:
- app-network
nginx-proxy:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx-proxy.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
networks:
- app-network
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
volumes:
storage:
logs:
networks:
app-network:
driver: bridge
Nginx Proxy Configuration (SSL)
# docker/nginx-proxy.conf
events {
worker_connections 1024;
}
http {
upstream laravel {
server app:80;
}
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name your-domain.com www.your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
proxy_pass http://laravel;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
}
}
Step 4: Set Up Managed Services
Instead of running PostgreSQL and Redis in Docker, use DanubeData managed services for reliability and automatic backups:
Create PostgreSQL Database
- Log into DanubeData dashboard
- Navigate to Databases → Create Database
- Select PostgreSQL, choose your plan (Small: €19.99/mo recommended)
- Copy the connection credentials
Create Redis Cache
- Navigate to Cache → Create Cache Instance
- Select Redis, choose Micro (€4.99/mo) or Small (€9.99/mo)
- Copy the connection credentials
Configure Firewall Rules
Add your VPS IP address to both database and cache firewall rules to allow connections.
Step 5: Environment Configuration
# .env.production
APP_NAME="Your App"
APP_ENV=production
APP_KEY=base64:your-key-here
APP_DEBUG=false
APP_URL=https://your-domain.com
LOG_CHANNEL=stack
LOG_LEVEL=error
# DanubeData PostgreSQL
DB_CONNECTION=pgsql
DB_HOST=your-db.danubedata.com
DB_PORT=5432
DB_DATABASE=your_database
DB_USERNAME=your_user
DB_PASSWORD=your_password
# DanubeData Redis
REDIS_HOST=your-cache.danubedata.com
REDIS_PASSWORD=your_redis_password
REDIS_PORT=6379
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
# Mail (example with SMTP)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=your-username
MAIL_PASSWORD=your-password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="hello@your-domain.com"
MAIL_FROM_NAME="${APP_NAME}"
Step 6: Deploy to VPS
Initial Server Setup
# SSH into your VPS
ssh root@your-vps-ip
# Install Docker
curl -fsSL https://get.docker.com | sh
apt install docker-compose-plugin -y
# Create app directory
mkdir -p /var/www/laravel
cd /var/www/laravel
# Clone your repository
git clone https://github.com/your-org/your-app.git .
# Copy environment file
cp .env.production .env
# Generate app key if needed
docker compose run --rm app php artisan key:generate --show
# Copy the key to .env
# Initial SSL certificate
docker compose run --rm certbot certonly
--webroot --webroot-path=/var/www/certbot
-d your-domain.com -d www.your-domain.com
# Build and start
docker compose up -d --build
# Run migrations
docker compose exec app php artisan migrate --force
# Cache configuration
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Check status
docker compose ps
docker compose logs -f app
Step 7: Zero-Downtime Deployments
Create a deployment script for zero-downtime updates:
#!/bin/bash
# deploy.sh
set -e
echo "🚀 Starting deployment..."
cd /var/www/laravel
# Pull latest code
echo "📥 Pulling latest code..."
git pull origin main
# Build new container without stopping current one
echo "🔨 Building new container..."
docker compose build app
# Put app in maintenance mode
echo "🔧 Enabling maintenance mode..."
docker compose exec app php artisan down --retry=60
# Run migrations
echo "📊 Running migrations..."
docker compose exec app php artisan migrate --force
# Clear and rebuild caches
echo "🧹 Clearing caches..."
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Restart containers (minimal downtime)
echo "🔄 Restarting containers..."
docker compose up -d --no-deps app
# Wait for container to be healthy
echo "⏳ Waiting for container..."
sleep 10
# Restart queue workers
echo "🔄 Restarting Horizon..."
docker compose exec app php artisan horizon:terminate
# Disable maintenance mode
echo "✅ Disabling maintenance mode..."
docker compose exec app php artisan up
echo "🎉 Deployment complete!"
Step 8: GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, redis, gd, zip, intl
- name: Install Dependencies
run: composer install --prefer-dist --no-progress
- name: Run Tests
run: php artisan test
env:
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/laravel
./deploy.sh
Step 9: Monitoring and Maintenance
View Logs
# Application logs
docker compose logs -f app
# Laravel logs
docker compose exec app tail -f storage/logs/laravel.log
# Horizon logs
docker compose exec app cat /var/log/supervisor/horizon.log
Monitor Horizon
# Check Horizon status
docker compose exec app php artisan horizon:status
# List pending jobs
docker compose exec app php artisan queue:monitor redis:default
# Access Horizon dashboard (add route protection first!)
# https://your-domain.com/horizon
Database Maintenance
# Backup is automatic with DanubeData, but you can also:
docker compose exec app php artisan backup:run
# Clear old logs
docker compose exec app php artisan log:clear
Step 10: Performance Optimization
Enable OPcache Preloading (PHP 8.3)
// config/opcache-preload.php
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(IlluminateContractsConsoleKernel::class)->bootstrap();
# docker/php.ini
opcache.preload=/var/www/html/config/opcache-preload.php
opcache.preload_user=www-data
Database Query Optimization
// Add to AppServiceProvider::boot()
if ($this->app->environment('production')) {
DB::disableQueryLog();
}
// Use eager loading
$users = User::with(['posts', 'comments'])->get();
// Index your frequently queried columns
Schema::table('posts', function (Blueprint $table) {
$table->index(['user_id', 'created_at']);
});
Redis Pipeline for Bulk Operations
use IlluminateSupportFacadesRedis;
Redis::pipeline(function ($pipe) use ($items) {
foreach ($items as $item) {
$pipe->set("item:{$item->id}", serialize($item));
}
});
Complete Architecture
┌─────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Nginx Proxy (SSL) │
│ Port 80 → 443 │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Laravel Application │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Nginx │ │ PHP-FPM │ │ Horizon │ │
│ │ (static) │ │ (app) │ │ (queues) │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Supervisor (process manager) │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────┬─────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ PostgreSQL │ │ Redis │
│ (DanubeData) │ │ (DanubeData) │
│ €19.99/mo │ │ €4.99/mo │
│ │ │ │
│ • Automated backups │ │ • Sessions │
│ • SSL included │ │ • Cache │
│ • Point-in-time recovery │ │ • Queues │
└───────────────────────────┘ └───────────────────────────┘
Cost Summary
| Component | DanubeData Service | Monthly Cost |
|---|---|---|
| Application Server | VPS Small (2 vCPU, 4GB) | €8.99 |
| Database | PostgreSQL Small | €19.99 |
| Cache/Queues | Redis Small | €9.99 |
| Total | €38.97/month |
Compare this to Laravel Forge ($12) + DigitalOcean VPS ($48) + DO Database ($15) + DO Redis ($15) = $90+/month.
Troubleshooting
Permission Issues
# Fix storage permissions
docker compose exec app chown -R www-data:www-data storage bootstrap/cache
docker compose exec app chmod -R 775 storage bootstrap/cache
Horizon Not Processing Jobs
# Check Redis connection
docker compose exec app php artisan tinker
>>> Redis::ping()
=> "PONG"
# Restart Horizon
docker compose exec app php artisan horizon:terminate
Database Connection Issues
# Test connection
docker compose exec app php artisan tinker
>>> DB::connection()->getPdo()
# Check firewall rules in DanubeData dashboard
Get Started
Ready to deploy your Laravel application?
👉 Create your free DanubeData account
Set up your complete Laravel stack in under 10 minutes:
- VPS with Docker pre-installed
- Managed PostgreSQL with automatic backups
- Redis for caching and queues
All for just €38.97/month with European data residency and priority support included.
Need help with your Laravel deployment? Contact our team—we're Laravel developers too.