Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
Инструменты

Docker Compose для локальной разработки PHP: php-fpm, nginx, MySQL

Собираем рабочее окружение для PHP-проекта из трёх контейнеров: php-fpm, nginx и MySQL. Разбираем volumes, healthcheck и профили, после которых одна команда поднимает весь стек без ручных настроек.

19 мая 20269 мин чтенияПрактикам
Docker Compose для локальной разработки PHP: php-fpm, nginx, MySQL
3 сервисаphp-fpm, nginx, MySQL
1 командаподнимает весь стек

Локальная разработка PHP-проекта обычно требует как минимум трёх частей: процесс, который исполняет PHP-код, веб-сервер, который отдаёт статику и проксирует динамику, и базу данных. Собирать всё это руками на каждой новой машине медленно и ненадёжно: версии PHP расходятся, настройки nginx теряются, база данных живёт в состоянии, которое никто не помнит, как воспроизвести. Docker Compose описывает весь стек одним файлом, и после docker compose up окружение одинаково у всех в команде.

Структура проекта и общая идея

Типичный проект на PHP с Compose держит рядом с кодом файл docker-compose.yml и папку docker/ с конфигами сервисов: Dockerfile для PHP, конфиг nginx, иногда конфиг php.ini для локальной разработки. Сам код приложения не копируется внутрь образов, а монтируется как volume, поэтому изменения в редакторе сразу видны внутри контейнера без пересборки.

  • src/ - код приложения
  • docker/php/Dockerfile, docker/nginx/default.conf - конфиги сервисов
  • docker-compose.yml и .env - описание стека и переменные окружения
Главная мысльЛокальное окружение, собранное через Compose, можно удалить и поднять заново за минуту, и это главное отличие от окружения, настроенного руками.

Сервис php-fpm: собственный образ вместо готового

Готовый образ php:8.3-fpm подходит для старта, но в реальном проекте почти всегда нужны дополнительные расширения: pdo_mysql, intl, opcache, иногда redis или gd. Поэтому практичнее собрать собственный слой поверх официального образа.

Такой Dockerfile наследуется от php:8.3-fpm и одной командой docker-php-ext-install pdo_mysql intl opcache добавляет нужные расширения, после чего WORKDIR /var/www/html задаёт рабочую директорию, в которую позже монтируется код проекта.

В файле docker-compose.yml сервис php ссылается на этот Dockerfile через build, а код проекта подключается volume'ом в ту же директорию, что указана в WORKDIR.

Сервис nginx: минимальный конфиг под php-fpm

Nginx в этой схеме не исполняет PHP сам, а передаёт запросы к .php-файлам в сервис php по протоколу FastCGI. Ключевая строка конфига: fastcgi_pass php:9000;, где php это не адрес, а имя сервиса из Compose, которое Docker автоматически резолвит внутри общей сети проекта.

# docker/nginx/default.conf
server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}
Не публикуйте порт 9000 сервиса php-fpm наружу через ports, он нужен только внутри сети Compose, для общения с nginx. Наружу публикуется только порт самого nginx.
Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Volumes: что монтировать и что не трогать

Основной volume - код приложения, смонтированный как ./src:/var/www/html, чтобы правки в редакторе сразу попадали в контейнер. Отдельно стоит завести именованный volume для данных MySQL, чтобы база не терялась при пересборке контейнеров и не хранилась внутри самого образа.

Что монтируемТип volumeЗачем
Код приложенияbind mount (./src)мгновенно видеть правки без пересборки
Данные MySQLименованный volumeданные переживают docker compose down
Конфиг nginxbind mount, read-onlyменять конфиг без пересборки образа
Логи php-fpmbind mount в ./docker/logsсмотреть логи снаружи контейнера

Healthcheck: не стартовать приложение раньше базы

Типичная проблема первого запуска стека: контейнер php поднимается раньше, чем MySQL внутри своего контейнера успевает инициализировать базу, и первые запросы к базе падают с ошибкой подключения. Директива depends_on без условия решает только порядок запуска контейнеров, но не порядок готовности сервисов внутри них. Правильное решение: healthcheck у MySQL и условие service_healthy у зависимого сервиса.

services:
  mysql:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: app
      MYSQL_ROOT_PASSWORD: secret
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

  php:
    build: ./docker/php
    depends_on:
      mysql:
        condition: service_healthy
    volumes:
      - ./src:/var/www/html

volumes:
  mysql_data:

После такой настройки docker compose up дождётся, пока MySQL ответит на mysqladmin ping, и только потом стартует сервис php. Это убирает классическую гонку при первом запуске проекта на новой машине.

Профили: не поднимать лишнее

Не каждому разработчику в команде нужны все вспомогательные сервисы одновременно: кому-то нужен Redis для кеша, кому-то Mailhog для проверки писем, а кто-то работает только с базовым стеком. Профили Compose позволяют пометить такие сервисы и поднимать их по требованию, не раздувая команду запуска по умолчанию.

services:
  redis:
    image: redis:7
    profiles: ["cache"]

  mailhog:
    image: mailhog/mailhog
    profiles: ["mail"]

Обычный запуск docker compose up поднимет только сервисы без профиля: php, nginx, mysql. Команда docker compose --profile cache --profile mail up добавит к ним Redis и Mailhog, когда они действительно нужны для текущей задачи. Это удобнее, чем держать два отдельных файла compose под разные сценарии.

Переменные окружения и .env

Пароли и параметры подключения к базе разумно держать не прямо в docker-compose.yml, а в файле .env рядом с ним, который Compose подхватывает автоматически. Это позволяет не коммитить реальные значения и держать в репозитории только пример .env.example с пустыми или тестовыми значениями.

  • .env в .gitignore, в репозитории только .env.example
  • значения из .env подставляются в compose через ${MYSQL_ROOT_PASSWORD}
  • для CI и продакшена значения приходят из отдельного секрет-хранилища, а не из локального файла

Частые ошибки при первом запуске стека

Первая типичная проблема - права на файлы, созданные внутри контейнера. Если процесс php-fpm внутри контейнера работает от пользователя с другим UID, чем у разработчика на хост-машине, файлы кеша или логов, созданные приложением, становятся недоступны для редактирования снаружи без sudo. Решение - явно задать пользователя в Dockerfile через аргумент сборки с UID текущего пользователя, либо настроить права на смонтированную директорию заранее.

Вторая проблема - несовпадение версии PHP в контейнере и версии, под которую написан код приложения или composer.lock. Если образ собран на PHP 8.1, а зависимости требуют 8.3, установка через Composer завершится с ошибкой совместимости прямо внутри контейнера. Стоит зафиксировать версию PHP в Dockerfile явно и держать её синхронной с записью в composer.json, а не полагаться на тег latest, который может смениться без предупреждения при пересборке образа.

Третья проблема - забытый кеш конфигурации Laravel или Symfony, оставшийся от предыдущего окружения. Если в проекте есть закешированные пути или абсолютные адреса из другого окружения, после переноса на новую машину приложение может падать с непонятными ошибками путей, хотя сам стек Docker поднялся без единой ошибки. Перед первым запуском в новом окружении полезно явно очищать кеш фреймворка через стандартные консольные команды.

Отличие от окружения на Docker без Compose

Отдельные команды docker run под каждый сервис технически дают тот же результат, но требуют вручную поддерживать сеть между контейнерами, порядок запуска и список портов. Compose описывает всё это декларативно в одном файле, который можно положить в репозиторий и обновлять вместе с кодом. Для команды из нескольких человек это сокращает цепочку «настрой окружение по инструкции в вики» до одной команды docker compose up, которая работает одинаково на macOS, Linux и внутри WSL на Windows.

Полезная привычка для команды из нескольких разработчиков - держать в репозитории короткий скрипт bin/setup.sh, который клонирует .env.example в .env при первом запуске, поднимает стек и прогоняет миграции базы одной командой. Это убирает ручные шаги из инструкции в вики и превращает запуск проекта в действие, которое новый человек в команде выполняет в первый рабочий день без вопросов в чат.

Отдельный вопрос - производительность файловой системы на macOS, где bind mount кода внутрь контейнера исторически был заметно медленнее, чем на Linux, из-за прослойки виртуализации. Современные версии Docker Desktop с движком VirtioFS снимают большую часть этой проблемы, но на крупных проектах с десятками тысяч файлов разница в скорости установки зависимостей и прогрева кеша фреймворка всё ещё заметна по сравнению с нативным Linux-окружением. Если локальная разработка ощутимо тормозит именно на операциях с файлами, стоит проверить, какой движок виртуализации выбран в настройках Docker Desktop, прежде чем искать проблему в самом приложении.

Держите docker-compose.yml и Dockerfile под контролем версий вместе с кодом приложения, а не отдельно у каждого разработчика на своей машине: тогда обновление версии PHP или MySQL в проекте становится обычным пул-реквестом, а не устным сообщением в чате.

В сумме такой стек из трёх сервисов с healthcheck, volumes под данные и профилями под опциональные зависимости закрывает почти все нужды локальной разработки PHP-проекта среднего размера и легко переносится на новую машину одним клонированием репозитория.

Частые вопросы

Нужен ли отдельный контейнер для Composer?

Для разовых команд удобнее не держать отдельный постоянный сервис, а запускать Composer через одноразовый контейнер: docker compose run --rm php composer install. Это использует тот же образ php, что и основной сервис, и не добавляет лишний процесс в общий стек.

Как подключиться к базе MySQL из контейнера снаружи, например из DBeaver?

Нужно опубликовать порт сервиса mysql в секции ports, например 3306:3306, и подключаться к localhost с этим портом. Это не обязательно для работы самого приложения, php обращается к mysql по внутреннему имени сервиса, порт наружу нужен только для сторонних инструментов на хост-машине.

Что делать, если после docker compose up база данных не видна в приложении?

Сначала проверьте, что php действительно ждёт готовности mysql через healthcheck и condition service_healthy, а не просто depends_on без условий. Затем проверьте имя хоста в строке подключения приложения, это должно быть имя сервиса из compose, например mysql, а не localhost, который внутри контейнера php указывает на сам контейнер.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор