Laravel at 6ms: Production-Ready Stack with Traefik, Octane, and FrankenPHP

Written by danielpetrica | Published 2025/10/29
Tech Story Tags: programming | laravel-octane | laravel-deployment-guide | php-8.4-performance | laravel-performance | docker-compose-laravel | traefik-reverse-proxy | frankenphp

TLDRA practical guide to deploying a high-performance Laravel stack using Octane, FrankenPHP, and a fully automated Docker Compose workflow.via the TL;DR App

Even with optimizations, this process still takes 40 to 60 ms on my machine with PHP 8.4. Luckily, for years, the PHP and Laravel worlds have had a solution that dramatically reduces this load time: Laravel Octane and FrankenPHP. The booting time for the Laravel framework can drop to just 4 to 6 ms per request. Incredible, isn't it?

Now, if you're new to Laravel Octane or FrankenPHP, you may wonder: How is this possible?

The simple answer is that the framework is kept in memory. After FrankenPHP starts, Laravel is always ready to serve requests without a full reboot. The real explanation is more complex and out of scope for this article. If you're curious, you can read the official Laravel Octane and FrankenPHP docs for a deeper dive.

Before continuing, I should mention that FrankenPHP isn't the only application server for Laravel Octane. However, it's the one I've tried, and I was so satisfied with its features and performance that I didn't feel the need to try the others (like Swoole or RoadRunner).

The Deployment Challenge: From Manual Start to Automation

Great, but how can I run this on my server?

That's a good question. First off, Laravel Octane exposes a very convenient command: php artisan octane:start. Since this is a long-lived process (meaning it keeps running for days or more), we can't just start it manually and walk away. This is where a process manager, like Supervisor, comes into play. You tell the process manager to start the process, and it runs it in the background and takes care of restarting it if it crashes or the system reboots.

But using a system like Supervisor has one very big downside, in my opinion: system pollution. That's why I chose another solution: Docker Compose.

Why Docker Compose is the Perfect Fit

Docker Compose has some huge advantages that allow me to make the most of Laravel Octane and its related tools.


Isolation


By building a custom Docker image, I can bake in just the minimal requirements to run each process. This allows me to separate the software/asset compilation (I'm looking at you, humongous node_modules folder) from the final running application.

For example, my web server image (which I just call app) only has the FrankenPHP requirements. It doesn't even include Composer, as the vendor directory is copied from a separate build stage. On the other hand, my worker image only includes the PHP CLI without FrankenPHP, because it doesn't need it.


Process Management


By separating every process that needs to run (horizon, pulse, scheduler, redis, db, web) into its own container, we ensure that a problem with one doesn't directly impact another. Of course, if the database dies, the app dies too, but if the scheduler dies, the app may continue working, perhaps with reduced functionality.

Plus, if a container dies, Docker automatically restarts it without human intervention.


Easy Traefik Integration


It's probably obvious from my blog, but I love Traefik. With it, I can run 30 web applications composed of nearly 80 containers with minimal interference. The only slowdown is when I build a new Docker image directly on the server, which can hog CPU resources. With Traefik, I can just place some labels on the container in my docker-compose.yml file, and Traefik automatically exposes it to the web on ports 80 and 443.

The stack

So, now that I've explained the what and the why, let's look at the infrastructure I deployed to run my site, coz.jp.

The stack is composed of three main parts.

1. The Multi-Stage Dockerfile


A multi-stage Dockerfile with multiple targets supports building lean, specialized images for the frankenphp web server and the command-line worker.

Dockerfile

A multi stage dockerfile image with multiple targets supported, frankenphp for the web and worker for non web process.

# Stage 1: Vendor (shared between all)
FROM composer:latest AS vendor
WORKDIR /app

# Install PHP extensions (needed for composer)
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions gd bcmath intl pcntl redis pdo_mysql

ARG SPARK_USERNAME
ARG SPARK_API_TOKEN
ENV SPARK_USERNAME=${SPARK_USERNAME}
ENV SPARK_API_TOKEN=${SPARK_API_TOKEN}

COPY composer.json composer.lock ./
#RUN composer config http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"
#RUN composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress

# Temporary Spark auth for private packages
RUN set -eux; \
    composer config --global http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"; \
    composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress; \
    composer config --global --unset http-basic.spark.laravel.com; \
    rm -f /root/.composer/auth.json || true; \
    rm -f /app/.composer/auth.json || true; \
    rm -f /tmp/* /var/tmp/* || true

# Stage 2: Assets (build frontend with Node 22 + Yarn)
FROM node:22-alpine AS assets
WORKDIR /app

# Copy dependency manifests and install dependencies using Yarn (via Corepack)
COPY package.json yarn.lock ./
RUN corepack enable \
    && corepack prepare yarn@1.22.22 --activate \
    && yarn install --frozen-lockfile

# Copy only what is needed for building assets
COPY vite.config.js ./
COPY tailwind.config.js ./
COPY postcss.config.js ./
COPY resources ./resources
COPY public ./public

ENV NODE_ENV=production
RUN yarn build

# Stage 3: Worker image (CLI)
FROM php:8.4-cli-alpine AS worker
COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis

ARG APP_ENV=production
WORKDIR /app
COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor

RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;

USER www-data
CMD ["php", "artisan", "queue:work", "--tries=3", "--sleep=1"]

# Stage 4: FrankenPHP image (Web)
FROM dunglas/frankenphp:latest AS frankenphp
WORKDIR /app

ARG APP_ENV=production
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis

COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor

# Copy compiled frontend assets without installing Node/Yarn in this stage
COPY --from=assets /app/public/build /app/public/build

COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/install-php-extensions
COPY --from=vendor /usr/bin/composer /usr/bin/composer

RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;

#EXPOSE 80
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80"]

This is my docker file code, not everything may make sense to you and there could be space for improvements for sure, fill free to suggest me your improvements

As you can see, I use a vendor stage to download all Composer packages, including private ones. I then explicitly remove the authentication credentials from the image to reduce security risks. The other two PHP stages just copy the vendor directory without needing to run composer install in each final image.

A similar assets stage handles the frontend. I install everything with Node 22 and Yarn, build the production assets, and then copy only the compiled files into the final web image. This keeps the huge node_modules directory and Node itself out of my production container, which is a great plus in my book.

The compose.yml File

This file orchestrates all the services, linking them together and configuring them for production with Traefik.

# Docker Compose setup for local and production (Traefik) with FrankenPHP
# - Build a single image and reuse it for web, worker, and scheduler services
# - For production behind Traefik, set labels and SERVER_NAME appropriately

x-env: &default-env
  env_file:
    - .env

x-volumes: &laravel-volumes
  volumes:
    - ./.storage/logs/:/app/storage/logs
    - ./.storage/app/:/app/storage/app
    - ./.storage/framework/:/app/storage/framework
# helper map to merge env and volumes in one << per service
x-common: &common
  <<: [*default-env, *laravel-volumes]

name: coz_jp_${APP_ENV}

services:
  app:
    container_name: coz_jp_web_${APP_ENV}
    image: coz_jp:frankenphp
    pull_policy: never
    build:
      context: ${CONTEXT_LOCATION:-.}
      dockerfile: docker/Dockerfile
      target: frankenphp
      args:
        APP_ENV: ${APP_ENV:-production}
        SPARK_USERNAME: ${SPARK_USERNAME}
        SPARK_API_TOKEN: ${SPARK_API_TOKEN}
    <<: *laravel-volumes
    #
    labels:
        - traefik.enable=true
        - traefik.http.routers.coz_jp_${APP_ENV}-https.rule=Host(`${APP_DOMAIN}`)
        - traefik.http.routers.coz_jp_${APP_ENV}-https.tls=true
        - traefik.http.services.coz_jp_${APP_ENV}-https.loadbalancer.server.port=80
        - traefik.http.routers.coz_jp_${APP_ENV}-https.tls.certresolver=cloudflare
        - traefik.http.routers.coz_jp_${APP_ENV}-https.entrypoints=websecure
        #- "traefik.http.routers.coz_jp_${APP_ENV}.middlewares=forward-auth"
        #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Proto=https"
        #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Host=${APP_URL}"
    environment:
      SERVER_NAME: ${APP_DOMAIN:-:80}
      SERVER_ROOT: /app/public
    depends_on:
      - redis
      - db
    networks:
      - internal
      - traefik # uncomment in production
    restart: unless-stopped
    command: ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80", "--workers=8", "--log-level=info"]
    healthcheck:
      test: [
        "CMD-SHELL",
        "curl -fsS http://127.0.0.1:80/up || { rc=$$?; echo \"[healthcheck] GET /up failed with code $$rc\" >&2; exit 1; }"
      ]
      interval: 15s
      timeout: 5s
      retries: 20
      start_period: 10s

  worker:
    container_name: coz_jp_worker_${APP_ENV}
    image: coz_jp:worker
    pull_policy: never
    build:
        context: ${CONTEXT_LOCATION:-.}
        dockerfile: docker/Dockerfile
        target: worker
        args:
          APP_ENV: ${APP_ENV:-production}
          SPARK_USERNAME: ${SPARK_USERNAME}
          SPARK_API_TOKEN: ${SPARK_API_TOKEN}
    <<: *common
    command: ["php", "artisan", "horizon"]
#     command: ["php", "artisan", "queue:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 2s
      retries: 10

  scheduler:
    container_name: coz_jp_scheduler_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "schedule:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped

  pulse_check:
    container_name: coz_jp_pulse_check_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "pulse:check"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped

  pulse_work:
    container_name: coz_jp_pulse_work_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "pulse:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped

  db:
    image: mysql:8.2
    container_name: coz_jp_db_${APP_ENV}
    <<: *default-env
    environment:
      MYSQL_DATABASE: ${DB_DATABASE:-laravel}
      MYSQL_USER: ${DB_USERNAME:-laravel}
      MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
    ports:
      - "${DB_EXPOSE_PORT:-13306}:3306" # change or remove in production
    volumes:
      - ./.mysql-db/:/var/lib/mysql
    networks:
      - internal
    restart: always
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
      start_period: 30s

  redis:
    image: redis:alpine
    container_name: coz_jp_redis_${APP_ENV}
    volumes:
      - .redis/redis:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
    restart: unless-stopped

  # Optional: database backups using tiredofit/db-backup
  # db_backup:
  #   image: tiredofit/db-backup
  #   container_name: db_backup_coz_jp
  #   depends_on:
  #     - db
  #   volumes:
  #     - ./backups/:/backup
  #   <<: *default-env
  #   networks:
  #     - internal
  #   restart: always

networks:
  internal:
  traefik:
     external: true
     name: traefik

This is my current compose.yml file feel free to suggest improvements to it please.

In the compose.yml file, you can see how I've separated each logical part of the application (web, worker, scheduler, pulse) into its own service. This aligns with the reasons I mentioned earlier.


3. The run.sh Deployment Script


Finally, to run and deploy this process, I adopted a simple .sh script to automate all the steps. Now I only need to type bash run.sh, and the code gets pulled, built, and deployed automatically.

#!/bin/bash
# Define directories
GITFOLDER="../coz_jp"
LOCALFOLDER=$(pwd)

# Load environment variables from .env
source "$LOCALFOLDER/.env"

echo "***Pulling repo.";

# Change to the GIT folder, exit if fails
cd "$GITFOLDER" || exit 1

# Pull latest changes
git pull

# Return to local folder
cd "$LOCALFOLDER" || exit 1

echo "***Copying files";
# Copy compose.yml from git, overwriting local
cp -f "${GITFOLDER}/compose.yml" "$LOCALFOLDER/compose.yml"
cp -f "${LOCALFOLDER}/.env" "${GITFOLDER}/.env.${APP_ENV}"

# Ensure folders are owned by user 82
sudo chown -R 82:82 .storage
sudo chown -R 999:999 .redis .mysql-db

echo "***Builidng docker";
# Launch Docker containers with rebuild
docker compose build && \
  docker compose up -d --force-recreate && \
  # Change ownership of /app inside container as root
  docker compose exec -u root coz_jp_web_${APP_ENV} chown -R www-data: /app && \
  # Run Laravel optimize command inside container
  docker compose exec coz_jp_web_${APP_ENV} php artisan optimize

# Play terminal bell
echo -en "\007"

# Send notification about deployment
#curl -d "Coz.jp Deployed ${APP_ENV}" https://ntfy update url 

as alwasy feel free to suggest corrections.

The Folder Structure


All these files are designed and laid out in a way that allows for their execution in any environment, from local to staging and production. To achieve this, I have laid out the following file structure, which you may have guessed from the run.sh script.

This structure means I can just cd into an environment's directory and run the script to deploy the entire project easily.

  - project root
  - - Git source folder (coz.jp in my case)
  - - Stage env directory
  - - - .env
  - - - run.sh
  - - - .mysql-db
  - - - .redis
  - - - .storage
  - - Prod env directory
  - - - .env
  - - - run.sh
  - - - .mysql-db
  - - - .redis
  - - - .storage

Using this configuration and structure make so that just by going to the environment directory and executing the previusly mentioned command i can easily deploy the project.

Thank you for reading this far! If you found this helpful, why not give it a like below or share it with a fellow developer who might benefit? I run this blog and share these guides for free. This infrastructure took me weeks to perfect, building on years of experience with similar setups using PHP-FPM, Apache and Traefik. ❤️



Written by danielpetrica | Web Developer Working with Laravel, Vue.js and MySQL Will probably post about tech, programming, no-code and low-code
Published by HackerNoon on 2025/10/29