Это практическое руководство по развертыванию высокопроизводительного стека Laravel с использованием Octane, FrankenPHP и полностью автоматизированного процесса сборки и деплоя на базе Docker Compose.
Laravel — очень производительный фреймворк, но у стандартной архитектуры есть один существенный недостаток, связанный с особенностями работы PHP: при каждом запросе приходится заново поднимать весь фреймворк.
Даже с оптимизациями этот процесс на моей машине с PHP 8.4 занимает от 40 до 60 мс. К счастью, в экосистеме PHP и Laravel уже много лет существует решение, которое радикально сокращает это время загрузки: Laravel Octane и FrankenPHP. Время загрузки (bootstrap/boot) фреймворка может снизиться до 4–6 мс на запрос. Впечатляет, не так ли?
Если вы только начинаете работать с Laravel Octane или FrankenPHP, может возникнуть вопрос: как это вообще возможно?
Краткий ответ — фреймворк остаётся в памяти. После запуска FrankenPHP Laravel постоянно готов обрабатывать запросы без полной повторной инициализации. Полное объяснение сложнее и выходит за рамки этой статьи. Если хотите разобраться детальнее, обратитесь к официальной документации Laravel Octane и FrankenPHP.
Прежде чем продолжить, стоит отметить, что FrankenPHP — не единственный сервер приложений для Laravel Octane. Однако именно его я попробовал в работе и остался настолько доволен функциональностью и производительностью, что не стал тестировать другие варианты, такие как Swoole или RoadRunner.
Проблема развертывания: от ручного запуска к автоматизации
Отлично, но как запустить это у себя на сервере?
Вопрос закономерный. Прежде всего, Laravel Octane предоставляет удобную команду: php artisan octane:start. Поскольку это долгоживущий процесс (то есть он может работать сутками и дольше), просто запустить его вручную и забыть о нём нельзя. Здесь на помощь приходит менеджер процессов, например Supervisor. Вы настраиваете его на запуск процесса, и он выполняет его в фоновом режиме, а также автоматически перезапускает в случае сбоя или перезагрузки системы.
Однако у использования таких решений, как Supervisor, на мой взгляд, есть серьёзный недостаток — «засорение» системы. Поэтому я выбрал другой подход: Docker Compose.
Почему Docker Compose — оптимальный выбор
Docker Compose даёт ряд существенных преимуществ, позволяющих максимально эффективно использовать Laravel Octane и сопутствующие инструменты.
Изоляция
Создавая собственный Docker-образ, я могу включить в него только минимально необходимые зависимости для запуска конкретного процесса. Это позволяет отделить этап сборки зависимостей и ассетов (да, я про гигантскую папку node_modules) от финального исполняемого приложения.
Например, образ веб-сервера (я называю его просто app) содержит только всё необходимое для FrankenPHP. В нём даже нет Composer, поскольку каталог vendor копируется из отдельного этапа сборки. В свою очередь, образ worker включает только PHP CLI без FrankenPHP, потому что он там не нужен.
Управление процессами
Разнося каждый необходимый процесс (horizon, pulse, scheduler, redis, db, web) по отдельным контейнерам, мы добиваемся того, что проблема в одном компоненте не влияет напрямую на остальные. Конечно, если «умрёт» база данных, приложение тоже перестанет работать, но если упадёт планировщик, приложение может продолжить работу, возможно с урезанной функциональностью.
Кроме того, если контейнер завершится, Docker (при настроенной политике restart в Compose) автоматически перезапустит его без участия человека.
Простая интеграция с Traefik
Возможно, это и так очевидно по моему блогу, но я люблю Traefik. С ним я могу запускать 30 веб-приложений, состоящих почти из 80 контейнеров, с минимальными конфликтами и взаимным влиянием. Единственная заметная просадка бывает, когда я собираю новый Docker-образ прямо на сервере: это может сильно нагрузить процессор. С Traefik всё просто: достаточно добавить несколько меток (labels) контейнеру в файле docker-compose.yml, и Traefik автоматически начнёт проксировать сервис наружу через 80 и 443 (HTTP/HTTPS).
❗Я никогда не открываю доступ к базе данных из публичного интернета, и вам не рекомендую!
Стек
Теперь, когда я объяснил, что именно делаем и зачем, посмотрим на инфраструктуру, которую я развернул для своего сайта coz.jp.
Стек состоит из трёх основных частей.
1. Многоэтапный Dockerfile
Многоэтапный Dockerfile с несколькими целями сборками позволяет собирать лёгкие, специализированные образы для веб-сервера на FrankenPHP и для CLI-воркера (контейнера командной строки).
Dockerfile
Многоэтапный Dockerfile с поддержкой нескольких targets: frankenphp для веба и worker для процессов, не связанных с вебом.
# Этап 1: Vendor (общий для всех) FROM composer:latest AS vendor WORKDIR /app # Устанавливаем расширения PHP (нужны для 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 # Временная авторизация Spark для приватных пакетов 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 # Этап 2: Assets (сборка фронтенда на Node 22 + Yarn) FROM node:22-alpine AS assets WORKDIR /app # Копируем манифесты зависимостей и устанавливаем зависимости через Yarn (через Corepack) COPY package.json yarn.lock ./ RUN corepack enable \ && corepack prepare yarn@1.22.22 --activate \ && yarn install --frozen-lockfile # Копируем только то, что нужно для сборки ассетов 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 # Этап 3: Worker-образ (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"] # Этап 4: FrankenPHP-образ (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 # Копируем собранные фронтенд-ассеты без установки Node/Yarn на этом этапе 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"]
Это мой Dockerfile. Возможно, не всё в нём будет очевидно с первого взгляда, и наверняка есть пространство для улучшений, так что смело предлагайте свои идеи.
Как видите, я использую отдельный этап vendor, чтобы скачать все Composer-пакеты, включая приватные. Затем я явно удаляю учётные данные для аутентификации из образа, чтобы снизить риски безопасности. Два остальных PHP-этапа просто копируют каталог vendor, без необходимости выполнять composer install в каждом финальном образе.
Похожим образом устроен этап assets для фронтенда. Я устанавливаю всё через Node 22 и Yarn, собираю продакшен-ассеты, а затем копирую в финальный веб-образ только скомпилированные файлы. Это позволяет не тащить огромный каталог node_modules и сам Node в production-контейнер, что, по-моему, большой плюс.
Файл compose.yml
Этот файл координирует запуск сервисов, связывает их между собой и настраивает для продакшена с использованием Traefik.
# Настройка Docker Compose для локальной среды и продакшена (Traefik) с FrankenPHP # - Собираем один образ и переиспользуем его для сервисов web, worker и scheduler # - Для продакшена за Traefik корректно задайте labels и SERVER_NAME 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 # вспомогательная «карта» для объединения env и volumes в одном << для сервиса 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 # раскомментируйте в продакшене 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 healthcheck: test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"] interval: 15s timeout: 2s retries: 10 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 healthcheck: test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"] interval: 15s timeout: 2s retries: 10 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 healthcheck: test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"] interval: 15s timeout: 2s retries: 10 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" # измените или уберите в продакшене 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 # Опционально: бэкапы базы данных через 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
Это мой текущий файл compose.yml. Смело предлагайте улучшения.
В compose.yml видно, что я вынес каждую логическую часть приложения (web, worker, scheduler, pulse) в отдельный сервис. Это соответствует причинам, которые я описал выше.
3. Скрипт деплоя run.sh
Наконец, чтобы запускать и разворачивать всё это, я сделал простой .sh-скрипт, который автоматизирует все шаги. Теперь мне достаточно выполнить bash run.sh, и код автоматически подтягивается, собирается и деплоится.
#!/bin/bash # Определяем каталоги GITFOLDER="../coz_jp" LOCALFOLDER=$(pwd) # Загружаем переменные окружения из .env source "$LOCALFOLDER/.env" echo "***Подтягиваем изменения из репозитория."; # Переходим в каталог с git-репозиторием, выходим при ошибке cd "$GITFOLDER" || exit 1 # Подтягиваем последние изменения git pull # Возвращаемся в локальный каталог cd "$LOCALFOLDER" || exit 1 echo "***Копируем файлы"; # Копируем compose.yml из репозитория, перезаписывая локальный cp -f "${GITFOLDER}/compose.yml" "$LOCALFOLDER/compose.yml" cp -f "${LOCALFOLDER}/.env" "${GITFOLDER}/.env.${APP_ENV}" # Убеждаемся, что владельцем папок является пользователь 82 sudo chown -R 82:82 .storage sudo chown -R 999:999 .redis .mysql-db echo "***Собираем docker"; # Собираем образы и пересоздаём контейнеры docker compose build && \ docker compose up -d --force-recreate && \ # Меняем владельца /app внутри контейнера от root docker compose exec -u root coz_jp_web_${APP_ENV} chown -R www-data: /app && \ # Выполняем команду оптимизации Laravel внутри контейнера docker compose exec coz_jp_web_${APP_ENV} php artisan optimize # Звуковой сигнал в терминале echo -en "\007" # Отправка уведомления о деплое #curl -d "Coz.jp Deployed ${APP_ENV}" https://ntfy update url
Структура папок
Все эти файлы спроектированы и разложены так, чтобы их можно было запускать в любом окружении: от локального до staging и продакшена. Для этого я использую следующую структуру каталогов, которую вы, возможно, уже угадали по скрипту run.sh.
Такая структура означает, что я могу просто перейти (cd) в каталог нужного окружения и запустить скрипт, чтобы без лишних действий развернуть весь проект.
- корень проекта - - папка с исходниками из Git (в моём случае — coz.jp) - - каталог окружения stage - - - .env - - - run.sh - - - .mysql-db - - - .redis - - - .storage - - каталог окружения prod - - - .env - - - run.sh - - - .mysql-db - - - .redis - - - .storage
Эта конфигурация и структура позволяют мне просто зайти в каталог нужного окружения и выполнить команду, упомянутую выше, чтобы легко развернуть проект.

Если Laravel у вас уже в проде (или скоро будет), стоит системно разобрать безопасность, тестирование, деплой, диагностику и встроенные инструменты — то, что обычно копится кусками. На курсе Framework Laravel эти темы проходят на практике, чтобы уверенно поддерживать коммерческие проекты и расти до middle+/senior+. Готовы к обучению? Пройдите вступительный тест.
Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:
25 февраля, 20:00. «Разработка REST API на Laravel». Записаться
10 марта, 20:00. «Мультитенантная архитектура в Laravel: как масштабировать проекты под десятки и сотни клиентов». Записаться
19 марта, 20:00. «Создаем современный монолит на Inertia: Laravel + Vue.JS». Записаться
