Меню
Docker для Laravel-приложений: пошаговая настройка

Docker для Laravel-приложений: пошаговая настройка

За последние годы я перепробовал всё: Homestead, Valet, «голый» Sail, самописные конфиги на nginx + PHP-FPM. Мой рабочий стандарт на текущий момент выглядит иначе: FrankenPHP вместо связки nginx + PHP-FPM, multi-stage-сборка с BuildKit-кешами и один compose.yaml без устаревшей секции version.

Ниже — схема, которую я использую как основу для новых проектов.

Мой базовый стек: Laravel 13, PHP 8.5, PostgreSQL 18, Redis 8, Node 24 LTS и Docker Compose.

Конкретные patch-версии образов перед началом проекта всё равно стоит проверять: они регулярно обновляются.

Почему я ушёл от nginx + PHP-FPM

Классическая схема требовала минимум двух контейнеров, отдельного взаимодействия между nginx и PHP-FPM и дополнительного конфига веб-сервера.

FrankenPHP объединяет веб-сервер и PHP в одном контейнере. Кроме обычной работы PHP-приложений, он хорошо интегрируется с Laravel Octane и позволяет использовать worker-режим, при котором приложение не загружается заново на каждый HTTP-запрос. Laravel официально поддерживает запуск Octane через FrankenPHP.

Но здесь есть важный нюанс: FrankenPHP сам по себе не устанавливает Laravel Octane в проект. Для команды php artisan octane:frankenphp в composer.json должен присутствовать пакет laravel/octane.

Устанавливаем пакет через Composer:

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

Это важно учитывать при создании Dockerfile: без laravel/octane приведённые ниже команды запуска не заработают.

Worker-режим при этом требует аккуратности. Octane держит приложение в памяти между запросами, поэтому состояние, случайно сохранённое в singleton’ах, статических свойствах или других долгоживущих объектах, может попасть в следующий запрос.

Если проект старый и в нём много глобального состояния, я сначала запускаю FrankenPHP без агрессивного worker-режима, проверяю приложение и только потом включаю Octane.

Шаг 1. Dockerfile

Вот базовый вариант Dockerfile:

# syntax=docker/dockerfile:1.10

ARG PHP_VERSION=8.5

# ---------- Базовый слой ----------
FROM dunglas/frankenphp:1-php${PHP_VERSION} AS base

ENV SERVER_NAME=:8080 \
    COMPOSER_ALLOW_SUPERUSER=1

RUN install-php-extensions \
        pcntl pdo_pgsql redis intl zip gd bcmath opcache \
    && curl -sS https://getcomposer.org/installer | php -- \
        --install-dir=/usr/local/bin \
        --filename=composer

WORKDIR /app

# ---------- PHP-зависимости ----------
FROM base AS vendor

COPY composer.json composer.lock ./

RUN --mount=type=cache,target=/tmp/composer-cache \
    COMPOSER_CACHE_DIR=/tmp/composer-cache \
    composer install \
        --no-dev \
        --no-scripts \
        --no-autoloader \
        --prefer-dist

# ---------- Фронтенд ----------
FROM node:24-alpine AS frontend

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .

RUN npm run build

# ---------- DEV ----------
FROM base AS development

RUN install-php-extensions xdebug

ENV XDEBUG_MODE=off

COPY docker/php/dev.ini /usr/local/etc/php/conf.d/zz-dev.ini

CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8080", "--watch"]

# ---------- PROD ----------
FROM base AS production

COPY docker/php/prod.ini /usr/local/etc/php/conf.d/zz-prod.ini

COPY --from=vendor /app/vendor ./vendor
COPY . .
COPY --from=frontend /app/public/build ./public/build

RUN composer dump-autoload \
        --optimize \
        --classmap-authoritative \
        --no-dev \
    && chown -R www-data:www-data storage bootstrap/cache

COPY docker/entrypoint.sh /usr/local/bin/entrypoint

RUN chmod +x /usr/local/bin/entrypoint

USER www-data

ENTRYPOINT ["entrypoint"]

CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8080", "--workers=auto"]

FrankenPHP предоставляет официальные Docker-образы для PHP 8.5, а Debian-варианты разработчики FrankenPHP рекомендуют как основной вариант.

Почему здесь multi-stage

В результате production-образ не содержит Node.js, npm-кеш и инструменты, необходимые только для сборки frontend-части.

Composer-зависимости тоже собираются отдельным слоем. Благодаря этому изменение PHP-кода не заставляет Docker заново выполнять composer install, если composer.lock не изменился.

BuildKit-кеш позволяет повторно использовать скачанные Composer-пакеты между сборками:

RUN --mount=type=cache,target=/tmp/composer-cache \
    ...

Аналогичный подход используется для npm:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

Почему config:cache я не выполняю при сборке

config:cache не стоит выполнять во время Docker build, если значения окружения должны приходить в контейнер уже при запуске.

Laravel рекомендует выполнять php artisan config:cache непосредственно в процессе production-деплоя. После создания кеша .env при обработке приложения уже не загружается, поэтому значения должны поступать из внешнего окружения.

Поэтому кеши Laravel я создаю в entrypoint.

Шаг 2. Entrypoint

docker/entrypoint.sh:

#!/bin/sh
set -e

if [ "$1" = "php" ] || [ "$1" = "artisan" ]; then
    php artisan config:cache
    php artisan route:cache
    php artisan event:cache
    php artisan view:cache

    if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
        php artisan migrate --force --isolated
    fi
fi

exec "$@"

Laravel официально рекомендует кешировать конфигурацию, события, маршруты и Blade-шаблоны в production.

—isolated полезен при нескольких репликах приложения: Laravel использует блокировку, чтобы не дать нескольким процессам одновременно выполнить одну и ту же миграцию.

Но здесь есть ещё один нюанс: механизм блокировки зависит от используемого cache store. В production он должен быть доступен всем репликам приложения.

И ещё: route:cache подходит не для абсолютно любого проекта. Если в маршрутах используются конструкции, которые нельзя закешировать, команду нужно проверять на конкретном приложении.

Шаг 3. compose.yaml для разработки

Я бы сделал development-окружение так:

name: myapp

services:
  app:
    build:
      context: .
      target: development
      args:
        PHP_VERSION: 8.5
    ports:
      - "8080:8080"
    volumes:
      - .:/app:cached
      - vendor:/app/vendor
    environment:
      DB_CONNECTION: pgsql
      DB_HOST: pgsql
      DB_PORT: 5432
      DB_DATABASE: myapp
      DB_USERNAME: myapp
      DB_PASSWORD: secret
      REDIS_HOST: redis
      REDIS_PORT: 6379
      XDEBUG_MODE: ${XDEBUG_MODE:-off}
    depends_on:
      pgsql:
        condition: service_healthy
      redis:
        condition: service_healthy

  vite:
    image: node:24-alpine
    working_dir: /app
    command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
    ports:
      - "5173:5173"
    volumes:
      - .:/app:cached
      - node_modules:/app/node_modules

  queue:
    build:
      context: .
      target: development
    command: php artisan queue:work --tries=3 --max-time=3600
    volumes:
      - .:/app:cached
      - vendor:/app/vendor
    environment:
      DB_CONNECTION: pgsql
      DB_HOST: pgsql
      DB_PORT: 5432
      DB_DATABASE: myapp
      DB_USERNAME: myapp
      DB_PASSWORD: secret
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      pgsql:
        condition: service_healthy
      redis:
        condition: service_healthy

  pgsql:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:8-alpine
    command: redis-server --appendonly yes
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"

volumes:
  pgdata:
  redisdata:
  vendor:
  node_modules:

Что здесь важно

Обратите внимание на три вещи. Первая — vendor вынесен в именованный том: это снимает главный тормоз на macOS и Windows, где синхронизация десятков тысяч файлов через bind mount убивает производительность. Вторая — depends_on с condition: service_healthy, иначе Laravel стартует раньше базы и падает на первом же коннекте. Третья — блок develop.watch: docker compose watch пересоберёт контейнер при изменении composer.lock и просто синхронизирует файлы при правке кода.

Шаг 4. Xdebug, который не тормозит постоянно

Я предпочитаю держать Xdebug установленным, но выключенным.

docker/php/dev.ini:

opcache.enable=0

xdebug.client_host=host.docker.internal
xdebug.start_with_request=yes
xdebug.discover_client_host=false

По умолчанию в Compose:

XDEBUG_MODE: ${XDEBUG_MODE:-off}

Когда нужен debugger:

XDEBUG_MODE=debug docker compose up

В результате Xdebug не вмешивается в обычную разработку, но его не нужно устанавливать заново при каждом переключении между обычной работой и отладкой.

Для постоянной работы с Xdebug я бы не оставлял:

xdebug.start_with_request=yes

и одновременно XDEBUG_MODE=debug включённым для всего проекта: debugger будет активироваться практически на каждом запросе.

Шаг 5. Production-настройки PHP

docker/php/prod.ini:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

opcache.jit=tracing
opcache.jit_buffer_size=128M

realpath_cache_size=4096K

memory_limit=256M
expose_php=0

opcache.validate_timestamps=0 логичен для immutable-контейнера: код внутри production-образа не должен изменяться после сборки. При деплое выходит новый образ, поэтому PHP не должен постоянно проверять timestamps файлов.

JIT при этом не стоит воспринимать как универсальный ускоритель Laravel. PHP действительно поддерживает tracing JIT, но основная выгода обычно проявляется на CPU-bound вычислениях, а не на типичном CRUD-приложении, где значительную часть времени занимают база данных, сеть, файловая система и внешние API.

Поэтому я бы включал JIT только после реальных замеров.

Что чаще всего ломается

Права на storage

Laravel должен иметь возможность писать в:

storage/
bootstrap/cache/

Это касается как обычного PHP, так и FrankenPHP. Laravel отдельно указывает эти каталоги как директории, которым необходимы права на запись.

В production я явно передаю владельца:

RUN chown -R www-data:www-data storage bootstrap/cache

В development ситуация сложнее: если файлы создаются пользователем контейнера www-data, а потом редактируются с host-машины, можно получить проблемы с UID/GID.

Для Linux-проектов я обычно решаю это через пользователя контейнера с UID/GID текущего пользователя.

Ассеты Vite

Vite внутри контейнера должен слушать не только localhost, а весь интерфейс:

export default defineConfig({
    server: {
        host: '0.0.0.0',
        hmr: {
            host: 'localhost',
        },
    },
});

Иначе Docker пробросит порт, но dev server будет доступен только внутри контейнера.

Размер Docker-контекста

Обязательно добавьте .dockerignore:

node_modules
vendor
.git
.env
.env.*
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
tests

Особенно важно не отправлять в build context .git, node_modules и vendor.

При этом не исключайте tests, если они нужны в CI-образе или используются отдельной стадией Dockerfile. .dockerignore должен соответствовать реальному процессу сборки проекта.

Healthcheck приложения

Для самого приложения я использую встроенный Laravel health route:

/up

В новых Laravel-приложениях этот endpoint создаётся через health routing и возвращает HTTP 200, если приложение успешно загрузилось. Он предназначен в том числе для load balancer, uptime monitoring и Kubernetes.

Важный момент: не нужно бездумно делать healthcheck вида, если задача healthcheck — определить, живо ли само приложение:

/up → SELECT 1 → Redis → внешний API

Чем больше зависимостей проверяет readiness probe, тем выше вероятность, что временная проблема одного сервиса приведёт к каскадному исключению приложения.

Если действительно нужно проверять БД или Redis, Laravel позволяет расширить стандартную health-проверку через DiagnosingHealth.

Что в итоге получается

В результате я получаю разделённое окружение:

  • FrankenPHP + Laravel Octane — приложение;
  • PostgreSQL 18 — база;
  • Redis 8 — кеш и очереди;
  • Node 24 — сборка и dev server Vite;
  • Mailpit — локальная почта;
  • Xdebug — установлен, но выключен по умолчанию;
  • BuildKit cache — ускоряет повторные сборки;
  • multi-stage Dockerfile — не тащит инструменты сборки в production.

Production-образ при этом содержит только то, что действительно необходимо для запуска Laravel.

Главное — не пытаться включить всё сразу. Сначала я бы добился стабильной production-сборки с FrankenPHP, Composer и корректным entrypoint. Затем добавил бы кеширование Laravel, healthcheck и очереди. И только после этого включал Octane worker mode и JIT, ориентируясь уже на реальные профили производительности.

Именно такой порядок даёт гораздо более предсказуемый результат, чем попытка ускорить приложение десятью оптимизациями одновременно.