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, ориентируясь уже на реальные профили производительности.
Именно такой порядок даёт гораздо более предсказуемый результат, чем попытка ускорить приложение десятью оптимизациями одновременно.