<? phpukraine СПІВБЕСІДИ
Пошук по платформі
DEVOPS · JUNIOR ЧАСТО ПИТАЮТЬ

Як влаштувати локальне середовище PHP-проєкту в Docker Compose?

Compose описує сервіси локального стенду: PHP (php-fpm поруч з nginx або один контейнер FrankenPHP), база, кеш; код заходить через bind mount, а vendor і дані БД живуть у named volumes, бо саме bind mount повільний на macOS та Windows; Xdebug ставлять в образ, але тримають у режимі off і вмикають змінною XDEBUG_MODE на час сесії.

Розкажіть, що у вас у compose.yaml на проєкті і навіщо там кожен сервіс.
Колега скаржиться, що на macOS сторінка відкривається 4 секунди, а на Linux ті самі 200 мс. Де шукати?
Чому php-fpm і nginx тримають окремими контейнерами, а не пхають в один?
Xdebug у вас у Docker постійно ввімкнений? Як тоді живуть тести?
Docker Docker Compose php-fpm nginx FrankenPHP Xdebug

Схема з двох контейнерів існує тому, що php-fpm не є вебсервером. Він слухає FastCGI (типово порт 9000) і чекає, поки хтось передасть йому вже розібраний запит. Цим займається nginx: статику з public/ він віддає сам, а .php проксує директивою fastcgi_pass app:9000. Звідси головна пастка новачка. root у конфізі nginx має вказувати на шлях, за яким код лежить у контейнері app: nginx лише передає php-fpm рядок SCRIPT_FILENAME, а відкриває файл уже сам php-fpm. Якщо код змонтований лише в один із контейнерів або шляхи різні, отримуєте порожню сторінку чи File not found без жодного натяку на причину. Імена сервісів усередині compose-мережі резолвляться автоматично, тому в конфігах і .env фігурують app, db, redis, а не localhost.

FrankenPHP прибирає цю пару: Caddy з PHP усередині, один процес, один контейнер, HTTPS без зайвих рухів, HTTP/2 і HTTP/3 з коробки. Виграш дає передусім worker mode, коли застосунок завантажується один раз і далі крутиться в циклі, обробляючи запити без повторного бутстрапу фреймворку. Laravel підключає це через Octane, Symfony має свій runtime. Плата очевидна: у worker mode стан переживає запит, і будь-яка статична властивість із даними користувача перетворюється на витік між сесіями. Для локального стенду розумний компроміс такий: якщо продакшен на php-fpm, тримайте php-fpm і локально; якщо на FrankenPHP, беріть FrankenPHP, але вмикайте воркери й на розробці, інакше специфічні баги ви побачите вперше на бойовому сервері.

З volumes усе впирається в операційну систему. На Linux контейнер працює в тому самому ядрі, тому bind mount не коштує нічого. На macOS і Windows Docker крутить Linux-VM, і кожне звернення до змонтованої хостової теки йде через прошарок: VirtioFS у Docker Desktop (доступний з 4.6, типовий з 4.15) або 9p чи gRPC-FUSE у старіших конфігураціях. Один stat() дешевий, але composer-автозавантаження на холодний запит відкриває тисячі файлів у vendor/, і затримка складається в секунди. Прапорці :cached і :delegated з часів osxfs у нинішньому Docker Desktop нічого не роблять. Працюють два підходи: винести vendor/, node_modules/ і теку кешів у named volumes, які лежать усередині VM, або взагалі відмовитися від монтування на користь docker compose watch (Compose 2.22 і новіші), який односторонньо синхронізує файли всередину контейнера. Під WSL2 діє простіше правило: тримайте репозиторій у файловій системі самого WSL, а не в /mnt/c, інакше проєкт ходить через міст до NTFS і гальмує так само.

Xdebug ставлять в образ, але не вмикають. Починаючи з версії 3 його поведінкою керує xdebug.mode, і в режимі off розширення практично не впливає на швидкість, бо не вішає хуки на виконання опкодів. Значення читається зі змінної середовища XDEBUG_MODE, тому в compose пишуть XDEBUG_MODE: "${XDEBUG_MODE:-off}" і піднімають сервіс з XDEBUG_MODE=debug тоді, коли справді треба ставити брейкпоінти. Другий рівень контролю дає xdebug.start_with_request=trigger: навіть у режимі debug сесія стартує лише за наявності cookie, параметра запиту або змінної XDEBUG_TRIGGER, тож фонові команди й черги не намагаються достукатися до IDE. Адресу для зворотного підключення задають через client_host=host.docker.internal, і на Linux до цього обов'язково потрібен extra_hosts із host-gateway, бо там такого імені за замовчуванням не існує. Окремо тримайте в голові покриття тестами: воно вимагає XDEBUG_MODE=coverage, і саме тому CI з увімкненим Xdebug на всіх кроках повільний. Альтернатива для покриття - PCOV, який робить одну річ, але швидко.

Останнє про межі. Compose-файл для розробки і продакшен-образ вирішують різні задачі: локально потрібні bind mount, dev-залежності, відкриті порти БД і людські помилки на екрані, у релізі - копія коду всередині образу, --no-dev, прогріті кеші. Збігатися вони мають в одному: версія PHP і набір розширень. Різниця тут повертається як «локально працює, на сервері ні», і жоден compose цього не врятує. Практично це роблять multi-stage збіркою, де dev і prod виходять з однієї базової стадії. Ще одна річ, яку зазвичай згадують запізно: стенд має підійматися з нуля однією командою, разом із міграціями і сідерами, інакше новий розробник витрачає перший день на з'ясування, чиї саме дампи вважаються актуальними.

# compose.yaml - стенд для розробки. Продакшен-образ збирається окремо.
services:
  app:                                   # php-fpm: слухає FastCGI на 9000, HTTP не вміє
    build:
      context: .
      target: dev                        # окремий stage із Xdebug і dev-залежностями
    volumes:
      - .:/app                           # код правимо на хості, бачимо в контейнері
      - vendor:/app/vendor               # named volume: тисячі файлів не ходять через bind mount
    environment:
      XDEBUG_MODE: "${XDEBUG_MODE:-off}" # типово вимкнено, накладних витрат майже нема
      XDEBUG_CONFIG: "client_host=host.docker.internal"
    extra_hosts:
      - "host.docker.internal:host-gateway"  # на Linux цього імені немає, додаємо руками

  web:
    image: nginx:1.27-alpine
    ports: ["8080:80"]
    volumes:
      - ./public:/app/public:ro          # статику nginx віддає сам, PHP не турбує
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [app]                    # у конфізі: fastcgi_pass app:9000;
                                         # root /app/public - шлях однаковий в обох контейнерах

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - dbdata:/var/lib/postgresql/data  # datadir лише в named volume
    ports: ["5432:5432"]                 # щоб підключитися клієнтом з хоста

volumes:
  vendor:
  dbdata:

# docker compose run --rm app composer install
# XDEBUG_MODE=debug docker compose up -d app   # рестарт лише сервісу app на час дебагу
Розуміння, що php-fpm не вміє HTTP: nginx приймає запит, віддає статику сам, а PHP передає по FastCGI на `app:9000`, і обидва контейнери мають бачити код за однаковим шляхом.
Що FrankenPHP схлопує два сервіси в один (Caddy з вбудованим PHP), дає HTTPS і worker mode з коробки, але вимагає ZTS-збірки й уваги до стану між запитами.
Що bind mount на macOS і Windows коштує дорого на кожному syscall, тому `vendor/`, `node_modules/` і кеші виносять у named volumes або переходять на `docker compose watch`.
Що Xdebug 3 із `xdebug.mode=off` практично не має накладних витрат, а вмикають його змінною середовища `XDEBUG_MODE` і тригером, а не назавжди в php.ini.
Що compose-файл для розробки і продакшен-образ мають різні цілі, але однакову версію PHP і однаковий набір розширень, інакше «локально працює» нічого не означає.
Запхати nginx і php-fpm в один контейнер із supervisord, а потім не могти оновити одне без перезбирання іншого.
Змонтувати код лише в php-fpm і отримати 404 на статиці або порожню сторінку: nginx шукає `root` у себе, а там нічого немає.
Тримати дані Postgres чи MySQL у bind mount на хості й ловити помилки прав доступу та зіпсований datadir після `docker compose down -v`.
Ставити `xdebug.mode=debug` у php.ini образу і потім дивуватися, чому тести в CI ідуть утричі довше.
Писати `xdebug.client_host=host.docker.internal` і забути `extra_hosts: host.docker.internal:host-gateway` на Linux, де цього імені немає.
Тримати проєкт у `/mnt/c/...` під WSL2 замість файлової системи самого WSL і списувати гальма на Docker.
ПОРАДА

Скажіть, що compose-файл ви оцінюєте за трьома речами: чи однакова версія PHP з продакшеном, що саме змонтовано через bind mount, і чи можна запустити стенд без Xdebug. Далі покажіть на пальцях, що `vendor` у named volume, і поясніть, звідки береться різниця в швидкості між вашим Mac і CI на Linux. Це відповідь людини, яка чинила чужий стенд, а не копіювала compose.yaml зі статті.

оновлено 7 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

На Linux bind mount це той самий kernel, накладних витрат немає. На macOS і Windows контейнер працює у VM, і доступ до хостової теки йде через VirtioFS чи 9p: кожен stat() і open() коштує помітно дорожче, а autoload відкриває їх сотнями. Лікується перенесенням vendor/ у named volume, переходом на docker compose watch або зберіганням проєкту всередині WSL2.