BlogTutorialsLaravel Deployment on Managed Infrastructure: The Complete Guide 2025

Laravel Deployment on Managed Infrastructure: The Complete Guide 2025

Adrian Silaghi
Adrian Silaghi
December 25, 2025
15 min read
13 views
#laravel #php #deployment #docker #octane #horizon #postgresql #redis

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

  1. Log into DanubeData dashboard
  2. Navigate to Databases → Create Database
  3. Select PostgreSQL, choose your plan (Small: €19.99/mo recommended)
  4. Copy the connection credentials

Create Redis Cache

  1. Navigate to Cache → Create Cache Instance
  2. Select Redis, choose Micro (€4.99/mo) or Small (€9.99/mo)
  3. 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.

Share this article

Ready to Get Started?

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