Tutorial

Laravel Octane on VPS: FrankenPHP, Docker and Traefik

Development11 min read9 steps

PHP-FPM spawns a process per request: efficient for small apps, but expensive as traffic grows. Laravel Octane changes the paradigm — the application is loaded once into memory and handles thousands of requests without reinitializing. The gain is real: official Laravel benchmarks show 4× to 6× more requests/second compared to PHP-FPM 8.3. FrankenPHP, the recommended Octane driver since Laravel 11, embeds the HTTP server directly in the PHP binary — no nginx in front needed. On a VPS with root access, Docker Compose and Traefik, the migration is clean and reproducible.

Contents· PHP-FPM vs Laravel Octane — what really changes1/10
  1. 01PHP-FPM vs Laravel Octane — what really changes
  2. 02Six structural differences to understand
  3. 03Prerequisites before starting
  4. 04Deploy Laravel Octane + FrankenPHP + Traefik in Docker Compose
  5. 05Compatibility checklist before switching to production
  6. 068 points to check carefully
  7. 07FrankenPHP vs Swoole vs RoadRunner: choosing your Octane driver
  8. 08Monitor Octane in production with octane:status
  9. 09Troubleshooting: common production errors
  10. 10Going further

PHP-FPM vs Laravel Octane — what really changes

PHP-FPM follows the "shared nothing" model: every request loads the framework from scratch, executes the code, and frees all memory. It is robust by design — a leak in one request dies with it. Octane breaks this contract. The application is bootstrapped once, then requests flow through the same process. The performance gain is massive, but developer responsibility increases.

Six structural differences to understand

  • Shared lifecycle — Laravel's IoC container, providers and singletons are instantiated once and persist across all requests of a worker. A value written to a singleton in request N is still there in request N+1.
  • Documented performance gain — official Laravel Octane documentation benchmarks show 4× to 6× more requests/second versus PHP-FPM 8.3 on typical endpoints, by eliminating PHP bootstrap on every request.
  • Static variables = guaranteed leaks — any static property written during a request pollutes subsequent ones. Laravel Facades hide static references; some are cleaned by Octane, others are not.
  • Static files served by FrankenPHP — the binary includes a full HTTP server. On a VPS, no nginx in front of PHP is needed: FrankenPHP handles TLS, HTTP/2 and HTTP/3 natively, but behind Traefik it only receives plain HTTP on the internal Docker network.
  • Stable RSS memory ≠ leaked memory — process memory rises at startup (framework loading) then stabilizes. A real leak shows as continuous growth over thousands of requests, not the initial level.
  • Workers and concurrency — Octane starts --workers=N processes (FrankenPHP uses Zend Engine threads). Each worker keeps its own state; no shared memory between workers, but a single worker processes its requests sequentially.
  • Laravel 10 and 11 compatibility — Octane 2.x supports Laravel 10+. FrankenPHP as a driver is available since laravel/octane 2.3. PHP 8.2 minimum, PHP 8.3 recommended.

Prerequisites before starting

A VPS with at least 2 GB RAM (4 GB recommended for a full stack with Redis and queue workers), root access, Docker 25+ and Docker Compose v2. The application must run on Laravel 10 or 11, with laravel/octane 2.x installed (composer require laravel/octane). Traefik 3.x will manage Let's Encrypt certificates and HTTPS routing. No additional nginx is needed: FrankenPHP entirely replaces the HTTP layer.

Deploy Laravel Octane + FrankenPHP + Traefik in Docker Compose

  1. Install laravel/octane and publish the configuration

    In your Laravel project:

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

    This publishes config/octane.php and adds environment variables to .env.example. Verify that OCTANE_SERVER=frankenphp is set.

  2. Write the FrankenPHP Dockerfile

    FROM dunglas/frankenphp:1-php8.3-alpine
    
    RUN install-php-extensions \
        pdo_mysql \
        redis \
        opcache \
        pcntl \
        zip \
        intl
    
    WORKDIR /app
    
    COPY --chown=www-data:www-data . .
    
    RUN composer install --no-dev --optimize-autoloader --no-interaction \
     && php artisan config:cache \
     && php artisan route:cache \
     && php artisan view:cache
    
    EXPOSE 8000
    
    CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]

    The --max-requests=500 option restarts a worker after 500 requests: a safety net against residual memory leaks. Reduce to 100 while testing, then increase if memory remains stable.

  3. Write the complete docker-compose.yml

    services:
      app:
        build: .
        restart: unless-stopped
        environment:
          APP_ENV: production
          APP_KEY: ${APP_KEY}
          DB_CONNECTION: mysql
          DB_HOST: db
          DB_DATABASE: ${DB_DATABASE}
          DB_USERNAME: ${DB_USERNAME}
          DB_PASSWORD: ${DB_PASSWORD}
          REDIS_HOST: redis
          CACHE_DRIVER: redis
          SESSION_DRIVER: redis
          QUEUE_CONNECTION: redis
          OCTANE_SERVER: frankenphp
        networks:
          - internal
          - traefik
        labels:
          - "traefik.enable=true"
          - "traefik.http.routers.app.rule=Host(`${DOMAIN}`)"
          - "traefik.http.routers.app.entrypoints=websecure"
          - "traefik.http.routers.app.tls.certresolver=letsencrypt"
          - "traefik.http.services.app.loadbalancer.server.port=8000"
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      worker:
        build: .
        restart: unless-stopped
        command: ["php", "artisan", "queue:work", "redis", "--sleep=3", "--tries=3", "--max-time=3600"]
        environment:
          APP_ENV: production
          APP_KEY: ${APP_KEY}
          DB_HOST: db
          DB_DATABASE: ${DB_DATABASE}
          DB_USERNAME: ${DB_USERNAME}
          DB_PASSWORD: ${DB_PASSWORD}
          REDIS_HOST: redis
          QUEUE_CONNECTION: redis
        networks:
          - internal
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      scheduler:
        build: .
        restart: unless-stopped
        command: ["sh", "-c", "while true; do php artisan schedule:run >> /dev/null 2>&1; sleep 60; done"]
        environment:
          APP_ENV: production
          APP_KEY: ${APP_KEY}
          DB_HOST: db
          DB_DATABASE: ${DB_DATABASE}
          DB_USERNAME: ${DB_USERNAME}
          DB_PASSWORD: ${DB_PASSWORD}
          REDIS_HOST: redis
        networks:
          - internal
        depends_on:
          db:
            condition: service_healthy
    
      db:
        image: mysql:8.0
        restart: unless-stopped
        environment:
          MYSQL_DATABASE: ${DB_DATABASE}
          MYSQL_USER: ${DB_USERNAME}
          MYSQL_PASSWORD: ${DB_PASSWORD}
          MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
        volumes:
          - db_data:/var/lib/mysql
        networks:
          - internal
        healthcheck:
          test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      redis:
        image: redis:7-alpine
        restart: unless-stopped
        volumes:
          - redis_data:/data
        networks:
          - internal
        healthcheck:
          test: ["CMD", "redis-cli", "ping"]
          interval: 10s
          timeout: 3s
          retries: 5
    
    volumes:
      db_data:
      redis_data:
    
    networks:
      internal:
      traefik:
        external: true
  4. Deploy Traefik with automatic certificates

    Create a shared Docker network then start Traefik:

    docker network create traefik

    traefik.yml:

    entryPoints:
      web:
        address: ":80"
        http:
          redirections:
            entryPoint:
              to: websecure
              scheme: https
      websecure:
        address: ":443"
    
    certificatesResolvers:
      letsencrypt:
        acme:
          email: [email protected]
          storage: /letsencrypt/acme.json
          httpChallenge:
            entryPoint: web
    
    providers:
      docker:
        exposedByDefault: false
        network: traefik
    
    api:
      dashboard: false
    mkdir -p /opt/traefik/letsencrypt
    touch /opt/traefik/letsencrypt/acme.json
    chmod 600 /opt/traefik/letsencrypt/acme.json
    docker compose -f /opt/traefik/docker-compose.yml up -d
  5. Prepare the production .env file

    Critical variables to set on the VPS:

    APP_ENV=production
    APP_DEBUG=false
    APP_KEY=base64:...  # generated with php artisan key:generate
    DOMAIN=your-domain.com
    DB_DATABASE=laravel
    DB_USERNAME=laravel
    DB_PASSWORD=strong_password
    DB_ROOT_PASSWORD=root_strong_password
    OCTANE_SERVER=frankenphp
    CACHE_DRIVER=redis
    SESSION_DRIVER=redis
    QUEUE_CONNECTION=redis

    Never commit .env to git. On the VPS, manage it with scp or a secret manager.

  6. Run migrations without downtime

    Before switching to the new version, run migrations from an ephemeral container sharing the internal network:

    docker compose run --rm app php artisan migrate --force

    For zero-downtime deployment, use the --step flag on migrations that add NOT NULL columns — first add the column as nullable, deploy the app, then add the constraint in a second migration.

  7. Start the stack and verify the healthcheck

    docker compose up -d --build
    docker compose ps

    Wait for db and redis to reach healthy status before app starts (managed by depends_on: condition: service_healthy).

    Check Octane logs:

    docker compose logs -f app

    Normal output looks like:

    INFO  Server running on http://0.0.0.0:8000
    INFO  FrankenPHP workers: 4
  8. Check status with octane:status

    docker compose exec app php artisan octane:status

    This command shows the active server (frankenphp), the number of running workers and global status. A running response confirms workers are responding. If stuck, octane:reload restarts workers without shutting down the server.

  9. Configure hot-reload for deployments

    When deploying a new version, rebuild and reload without downtime:

    docker compose build app worker scheduler
    docker compose up -d --no-deps app worker scheduler

    With the Dockerfile in place, this sequence replaces containers one by one. For Octane, the reload signal (SIGUSR1) reloads workers without dropping active connections. Docker Compose handles signal delivery automatically via restart: unless-stopped.

Compatibility checklist before switching to production

Migrating to Octane does not require rewriting the application, but a few common patterns break silently. Go through this checklist on your code before the first deployment.

8 points to check carefully

  • Singletons with mutable state — any service registered with $this->app->singleton() that stores request-specific data (current user, locale, tenant ID) must be reset in an OctaneServiceProvider via Octane::tick() or by implementing a RequestReceived listener. Audit: grep -r 'singleton' app/Providers.
  • Sessions — with SESSION_DRIVER=redis, sessions correctly survive between requests. The file driver can create race conditions if multiple workers write simultaneously. Migrate to redis or database before switching to Octane.
  • Database connections — Octane maintains persistent DB connections per worker. Configure DB_POOL_MIN and DB_POOL_MAX in config/database.php if using a custom pool. Verify that MySQL accepts max_connections ≥ workers × replicas.
  • Static variables in models or services — static $cache = [] in an Eloquent model or repository persists between requests. Replace with request-scoped dependency injection, or explicitly clear in the RequestTerminated listener.
  • Non-idempotent event listeners — a listener registered in a ServiceProvider's boot() is only registered once, but fires on every request that triggers the event. No problem by itself, unless the listener accumulates global state (counter, static array).
  • Jobs and queues — Jobs do not run in the main Octane process but in the separate worker container. They retain the classic PHP-FPM lifecycle. No adaptations needed for Job code itself.
  • Uploaded files and storage — Storage::disk('local') works normally. Watch storage_path() in a Docker container: mount a volume for /app/storage so files persist between image rebuilds.
  • Third-party packages — some Laravel packages (Debugbar, Telescope) inject middlewares that accumulate data in memory. Disable them in production with APP_ENV=production or via conditions in config/debugbar.php. Check Octane compatibility on each package's repository.

FrankenPHP vs Swoole vs RoadRunner: choosing your Octane driver

Scroll the table

CriterionFrankenPHPSwooleRoadRunner
InstallationOfficial Docker image `dunglas/frankenphp` — no PECL extension to compilePECL extension `swoole` — requires `pecl install swoole` or a custom imageSeparate Go binary (`roadrunner`) — downloaded via Composer (`spiral/roadrunner`)
HTTP/2 and HTTP/3Native — FrankenPHP is built on Caddy which supports HTTP/3 (QUIC) out of the boxHTTP/2 supported, HTTP/3 not nativeHTTP/2 via plugin, HTTP/3 not natively supported
PHP extension compatibilityVery broad — based on official PHP-FPM, most extensions install normallyKnown incompatibilities with some blocking extensions (e.g. ext-curl in certain modes)Broad, but some complex PECL extensions may need adaptations
Official Laravel recommendationRecommended driver since Laravel 11 (2024), listed first in the Octane documentationFirst historical driver, still supported but less prominentSupported, alternative to Swoole, common in Symfony/Spiral ecosystems
Ops complexityLow — single container, minimal Dockerfile, no separate daemonMedium — the swoole extension must be compiled with the right PHP versionMedium — the RoadRunner binary must be synchronized with the Composer package version

Monitor Octane in production with octane:status

The php artisan octane:status command queries the FrankenPHP server in real time and shows the number of active workers, workers currently handling a request, and global status (running / stopped). Integrate it in your monitoring:

# Simple check in a health-check script
docker compose exec -T app php artisan octane:status | grep -q 'running' && echo OK || echo FAIL

To reload workers without dropping traffic (after a git pull without image rebuild):

docker compose exec app php artisan octane:reload

Track the RSS memory of the main process over time: continuous growth after thousands of requests signals a leak. The docker stats app command gives real-time memory; cross with --max-requests=N to force preventive recycling.

Troubleshooting: common production errors

Progressive memory leak: the app container memory grows without ever decreasing. Likely cause: a singleton accumulates data per request. Audit with php artisan octane:status over several thousand requests. Reduce --max-requests to 100 while identifying the source with Laravel Telescope (in staging) or a profiler like Blackfire.

502 Bad Gateway Traefik: Traefik can no longer reach the app container on port 8000. Check the container is on the traefik network (docker inspect app | jq '.[0].NetworkSettings.Networks'), that the label traefik.http.services.app.loadbalancer.server.port=8000 matches the exposed port, and the container is healthy (docker compose ps). A 502 at startup is often a missing depends_on toward the db container.

Worker restarting in a loop: Octane kills and recreates a worker if it stops responding within the configured timeout. Look for FATAL in docker compose logs app. Most common cause: an unexecuted migration that raises a QueryException during bootstrap. Run docker compose run --rm app php artisan migrate --force before starting the stack.

Sessions lost between requests: SESSION_DRIVER=file with multiple workers can cause reads on a file being written. Switch to SESSION_DRIVER=redis — the redis container is already in the docker-compose.yml above.

Wrong timezone or locale: in an Alpine container, the default timezone is UTC. Add APP_TIMEZONE=Europe/Paris (or your timezone) in .env and RUN apk add --no-cache tzdata in the Dockerfile if system extensions read /etc/localtime.

Going further

You now have a working Laravel Octane stack on VPS. The following articles cover the natural next steps: [Deploy Laravel on a VPS](/blog/deployer-laravel-vps) covers the base setup without Octane. [Deploy with Traefik](/blog/deployer-avec-traefik) deepens reverse proxy configuration (middlewares, rate-limiting, secured dashboard). [Docker Compose production checklist](/blog/docker-compose-production-checklist) lists 15 points to check before considering a Compose production-ready. [Host Redis on VPS](/blog/heberger-redis-vps) details Redis persistence, authentication and memory limits. [PHP migration checklist](/blog/php-82-fin-de-vie-checklist-migration) is required reading if your application still runs on PHP 8.2.

Deploy your infrastructure today

VPS Cloud. Preconfigured environments available.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab