Deploying Laravel with Docker: Setup Horizon, Reverb, Scheduler & Inertia SSR

Deploy production-ready Laravel applications using Docker with this comprehensive guide. Learn to configure Horizon queue workers, Reverb WebSockets, Inertia.js SSR, and choose between FPM-NGINX or FrankenPHP runtimes with Traefik integration.

Deploying Laravel with Docker: Setup Horizon, Reverb, Scheduler & Inertia SSR
Photo by Aditya Vyas / Unsplash

Introduction

This tutorial demonstrates how to deploy a Laravel application using Docker with a production-optimized setup. We'll configure all essential Laravel services including background jobs with Horizon, WebSocket support via Reverb, the task scheduler, and Inertia.js SSR rendering.

Our stack uses Traefik as a reverse proxy and leverages the serversideup/php Docker images, which extend official PHP images with production-ready security hardening, performance optimizations, and developer-friendly defaults.

Prerequisites

Before starting, ensure you have:

  • A Laravel application with the following features already configured:
    • Laravel Horizon (if using queues)
    • Laravel Reverb (if using WebSockets)
    • Inertia.js with SSR (if using React/Vue with server-side rendering)
    • Laravel Scheduler (if using scheduled tasks)
  • Traefik reverse proxy configured with Docker (follow the official Traefik Docker setup guide)
  • Docker and Docker Compose installed on your deployment server
  • Basic familiarity with Docker concepts and Laravel deployment requirements

Choosing Your PHP Runtime Image

The serversideup/php project provides two primary image variants optimized for different use cases:

FPM-NGINX combines PHP-FPM with NGINX as a reverse proxy in a single container. This traditional architecture offers the best balance of performance, stability, and compatibility for most Laravel applications. It's the recommended choice for standard deployments.

FrankenPHP is a modern application server built on the Caddy web server that runs PHP and the web server in a single process. This variant is designed for Laravel Octane and eliminates the complexity of managing separate PHP-FPM and web server processes. Choose this if you're using Octane for enhanced performance.

We'll cover deployment using both variants, starting with FPM-NGINX.

Deployment with FPM-NGINX

Configuring Trusted Proxies

Since your application runs behind Traefik, you need to configure Laravel to trust the proxy for proper IP address detection and URL generation.

For Laravel 11+, add the following to bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

For Laravel 10 and earlier, update app/Http/Middleware/TrustProxies.php:

protected $proxies = '*';

Creating the Dockerfile

Create a Dockerfile in the root of your Laravel project:

FROM serversideup/php:8.4-fpm-nginx AS laravel-app

USER root

# Install Node.js and npm for frontend asset compilation
RUN apt-get update && apt-get install -y nodejs npm

WORKDIR /var/www/html

# Copy dependency files first (enables Docker layer caching)
COPY composer.json composer.lock ./
COPY package.json package-lock.json ./

# Change ownership to www-data for proper permissions
RUN chown -R www-data:www-data /var/www/html

USER www-data

# Install PHP dependencies
RUN composer install --no-dev --no-scripts --optimize-autoloader

# Install JavaScript dependencies
RUN npm install

# Copy the full project with correct ownership
COPY --chown=www-data:www-data . .

# Build frontend assets with SSR support
RUN npm run build:ssr

This Dockerfile follows best practices by copying dependency files before the full project, which allows Docker to cache layers and speed up subsequent builds when only application code changes.

Configuring Docker Compose

Create a docker-compose.yml file in your project root:

services:
    app:
        build:
            context: .
            dockerfile: Dockerfile 
            target: laravel-app
        image: laravel-app
        restart: unless-stopped
        env_file:
            - .env
        environment:
            PHP_OPCACHE_ENABLE: "1"
            AUTORUN_ENABLED: "true"  # Automatically runs Laravel migrations and optimizations on container start
            HEALTHCHECK_PATH: "/up"  # Laravel 11+ health check endpoint
        volumes:
            - storage:/var/www/html/storage
            - bootstrap-cache:/var/www/html/bootstrap/cache
        depends_on:
            mysql:
                condition: service_healthy
            redis:
                condition: service_healthy
        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.testing-laravel.rule=Host(`${APP_HOST}`)"
            - "traefik.http.routers.testing-laravel.entrypoints=websecure"
            - "traefik.http.routers.testing-laravel.tls=true"
            - "traefik.http.services.testing-laravel.loadbalancer.server.port=8080"
        networks:
            - laravel
            - traefik-proxy

    mysql:
        image: mysql:8.0
        restart: always
        env_file:
            - .env
        environment:
            MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
            MYSQL_DATABASE: ${DB_DATABASE}
            MYSQL_USER: ${DB_USERNAME}
            MYSQL_PASSWORD: ${DB_PASSWORD}
        healthcheck:
            test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
            interval: 10s
            timeout: 5s
            retries: 5
        volumes:
            - mysql_data_storage:/var/lib/mysql
        networks:
            - laravel

    redis:
        image: redis:7
        restart: always
        healthcheck:
            test: ["CMD", "redis-cli", "ping"]
            interval: 10s
            timeout: 5s
            retries: 5
        networks:
            - laravel

    ssr:
        image: laravel-app
        pull_policy: never
        command: php artisan inertia:start-ssr
        restart: always
        depends_on:
            app:
                condition: service_started
        volumes: 
            - storage:/var/www/html/storage
            - bootstrap-cache:/var/www/html/bootstrap/cache
        env_file:
            - .env
        networks:
            - laravel

    scheduler:
        image: laravel-app
        pull_policy: never
        restart: unless-stopped
        depends_on:
            app:
                condition: service_started
        command: php artisan schedule:work
        volumes:
            - storage:/var/www/html/storage
            - bootstrap-cache:/var/www/html/bootstrap/cache
        env_file:
            - .env
        networks:
            - laravel

    horizon:
        image: laravel-app
        pull_policy: never
        restart: unless-stopped
        depends_on:
            app:
                condition: service_started
            redis:
                condition: service_healthy
        command: ["php", "/var/www/html/artisan", "horizon"]
        stop_signal: SIGTERM
        healthcheck:
            test: ["CMD", "healthcheck-horizon"]
            start_period: 10s
        env_file:
            - .env
        volumes:
            - storage:/var/www/html/storage
            - bootstrap-cache:/var/www/html/bootstrap/cache
        networks:
            - laravel

    reverb: 
        image: laravel-app
        pull_policy: never
        command: php artisan reverb:start
        restart: always
        depends_on:
            app:
                condition: service_started
        env_file:
            - .env
        volumes:
            - storage:/var/www/html/storage
            - bootstrap-cache:/var/www/html/bootstrap/cache
        labels: 
            - "traefik.enable=true"
            - "traefik.http.routers.laravel-reverb.rule=Host(`${REVERB_HOST}`)"
            - "traefik.http.routers.laravel-reverb.entrypoints=websecure"
            - "traefik.http.routers.laravel-reverb.tls=true"
            - "traefik.http.services.laravel-reverb.loadbalancer.server.port=${REVERB_PORT}"
        networks:
            - laravel
            - traefik-proxy

volumes:
    mysql_data_storage:
    storage:
    bootstrap-cache:

networks:
    laravel:
        driver: bridge
    traefik-proxy:
        external: true

Understanding Service Configuration

Application Service: The main Laravel application container handles web requests through NGINX and PHP-FPM. The AUTORUN_ENABLED environment variable triggers automatic execution of common deployment tasks like running migrations and clearing caches on container startup. The HEALTHCHECK_PATH points to Laravel 11's built-in /up health check route (for earlier Laravel versions, you'll need to create this route manually or adjust the path).

Database and Cache Services: MySQL and Redis both include health checks to ensure dependent services only start after these are fully operational.

Inertia SSR Service: Runs a separate Node.js process for server-side rendering of your Inertia.js components. Skip this service if you're not using Inertia with SSR.

Scheduler Service: Executes schedule:work to run your scheduled tasks without requiring cron configuration.

Horizon Service: Manages your queue workers with monitoring and configuration through the Horizon dashboard. The healthcheck-horizon command is provided by the serversideup image.

Reverb Service: Handles WebSocket connections for real-time features. The Traefik configuration routes WebSocket requests to this service using the configured REVERB_HOST and REVERB_PORT.

Environment Configuration

Make sure to have the following to your .env file:

APP_HOST=your-app.example.com
REVERB_HOST=ws.your-app.example.com
REVERB_PORT=8080
DB_DATABASE=db_name
DB_USERNAME=db_username
DB_PASSWORD=secure-password

For local development with Traefik, use the default Docker hostname format:

APP_HOST=laravel-app.docker.localhost
REVERB_HOST=reverb.docker.localhost
REVERB_PORT=8080
DB_DATABASE=db_name
DB_USERNAME=db_username
DB_PASSWORD=secure-password

Ensure your traefik-proxy network name matches the network you configured when setting up Traefik. If you used a different name, update the networks section accordingly.

Deployment with FrankenPHP (Alternative)

If you're using Laravel Octane for enhanced performance, you can deploy with FrankenPHP instead of FPM-NGINX.

Dockerfile Modifications

Change the base image in your Dockerfile:

FROM serversideup/php:8.4-frankenphp AS laravel-app

The rest of the Dockerfile remains the same. Note that you should install Laravel Octane in your project before building:

composer require laravel/octane
php artisan octane:install --server=frankenphp

Docker Compose Adjustments

Replace the app service configuration in your docker-compose.yml:

app:
    image: laravel-app
    restart: unless-stopped
    env_file:
        - .env
    environment:
        PHP_OPCACHE_ENABLE: '1'
        AUTORUN_ENABLED: 'true'
        HEALTHCHECK_PATH: '/up'
    volumes:
        - storage:/var/www/html/storage
        - bootstrap-cache:/var/www/html/bootstrap/cache
    depends_on:
        mysql:
            condition: service_healthy
        redis:
            condition: service_healthy
    command: ['php', 'artisan', 'octane:start', '--server=frankenphp', '--host=0.0.0.0', '--port=8080']
    healthcheck:
        test: ['CMD', 'healthcheck-octane']
        start_period: 10s
    labels:
        - "traefik.enable=true"
        - "traefik.http.routers.testing-laravel.rule=Host(`${APP_HOST}`)"
        - "traefik.http.routers.testing-laravel.entrypoints=websecure"
        - "traefik.http.routers.testing-laravel.tls=true"
        - "traefik.http.services.testing-laravel.loadbalancer.server.port=8080"
    networks:
        - laravel
        - traefik-proxy

The key differences are the custom command to start Octane with FrankenPHP and the healthcheck-octane health check command.

Testing and Verification

Build and start your containers:

docker-compose build
docker-compose up -d

Verify all services are running:

docker-compose ps

Check the logs for any errors:

docker-compose logs -f app

Access your application at the configured APP_HOST domain. If using Traefik's default local development setup, navigate to http://laravel-app.docker.localhost.

Common Troubleshooting

  • Permission errors: Ensure all files are owned by www-data inside containers
  • Database connection failures: Verify MySQL is healthy before app starts (depends_on with health checks)
  • Asset compilation failures: Check that Node.js and npm are properly installed in the Dockerfile
  • WebSocket connection issues: Confirm Reverb service has proper Traefik labels and network access
  • Trusted proxy errors: Verify you've configured trusted proxies as described in the configuration section

Conclusion

You now have a production-ready Laravel deployment using Docker with all essential services configured. This setup provides automatic health checks, graceful shutdowns, and isolated service management.

Refer to the serversideup/php documentation for advanced configuration options and the Traefik documentation for load balancing and advanced routing scenarios.