Развёртывание веб-приложения на связке Nginx, PHP и MySQL — одна из базовых задач разработчика и системного администратора. Раньше каждый сервис приходилось вручную настраивать на хосте и следить за конфликтами версий. С появлением Docker проблема решается проще: компоненты работают в изолированных контейнерах, а весь стек описывается одним файлом и поднимается одной командой. Разберём создание рабочего окружения пошагово.
Требования
Перед началом работы убедитесь, что на машине установлены:
- Ubuntu (в примерах — 24.04 LTS);
- актуальная версия Docker Engine;
- плагин Docker Compose 5.х (отвечает за совместный запуск нескольких контейнеров по единому описанию);
- редактор для работы с текстовыми файлами, например Visual Studio Code, Sublime Text, Notepad++ или Vim.
Как установить Docker на вашей операционной системе, подробно рассказано в отдельной инструкции. Понадобится и общее представление о том, как компоненты стека работают вместе:
- браузер отправляет HTTP-запрос;
- Nginx принимает его и передаёт PHP-FPM (FastCGI Process Manager) на обработку;
- PHP при необходимости обращается к MySQL.
Ещё до старта важно различать два понятия — образ и контейнер:
- Образ (image) — это шаблон, в котором есть всё необходимое для запуска приложения. Например, nginx:1.30-alpine и mysql:8.4 — готовые образы, которые Docker может скачать и использовать для запуска сервисов.
- Контейнер — запущенный экземпляр образа. В нашем проекте Nginx, PHP и MySQL будут работать в отдельных контейнерах. Контейнеры можно остановить, удалить и создать заново.
Дальше нужные образы указываются в docker-compose.yml: при запуске стека Docker сам скачает недостающие или соберёт их из Dockerfile.
Структура проекта
Код приложения и данные базы не стоит «зашивать» внутрь контейнера. Их выносят в тома (volumes) или прокидывают с хоста через bind mount, а сами контейнеры используют как среду выполнения для Nginx, PHP и MySQL.
- Bind mount напрямую связывает путь на хосте с путём в контейнере — так подключаются конфигурация Nginx и исходники сайта.
- Именованный volume управляется самим Docker и не привязан к конкретному каталогу на диске — в нём удобно хранить данные MySQL, которые должны пережить пересоздание контейнера.
У правила есть исключение: если приложение статично и не обновляется — например, лендинг, — код допустимо скопировать внутрь контейнера на этапе сборки. Тогда образ становится самодостаточным и легко переносится на любой сервер без дополнительных файлов. Но для CMS вроде WordPress или для проекта, который активно дорабатывается, код удобнее держать в томе. Для примера возьмём такую структуру каталогов:
Создание конфигурационного файла
Всё описание стека умещается в один файл docker-compose.yml в корне проекта (современный Compose принимает и имя compose.yaml — варианты равноценны). Он описывает три сервиса (nginx, php и mysql), их образы, порты, volumes и общую сеть, через которую контейнеры обращаются друг к другу по именам. Описание выглядит так:
services:
nginx:
image: nginx:1.30-alpine
container_name: myproject-nginx
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./src:/var/www/html:ro
depends_on:
- php
networks:
- app_network
php:
build:
context: ./php
container_name: myproject-php
user: "1000:1000"
volumes:
- ./src:/var/www/html
environment:
- DB_HOST=mysql
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
mysql:
condition: service_healthy
networks:
- app_network
mysql:
image: mysql:8.4
container_name: myproject-mysql
environment:
- MYSQL_DATABASE=${DB_NAME}
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- db_data:/var/lib/mysql
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- app_network
volumes:
db_data:
networks:
app_network:
driver: bridgeКод можно скопировать как есть, но версии образов и номера портов у вас могут отличаться. Вместо nginx:1.30-alpine, php:8.4-fpm (задан в Dockerfile) и mysql:8.4 можно указать любые другие поддерживаемые версии (или, например, mariadb вместо MySQL) — логика взаимодействия сервисов от этого не изменится. То же касается портов: если 80 или 3306 заняты на хосте, их несложно заменить, изменив только внешнюю часть проброса (например, "8081:80") — внутренние порты контейнеров трогать не нужно. Единственное, за чем стоит следить при смене версий, — совместимость PHP-расширений в Dockerfile с выбранной версией PHP.
Что важно учитывать:
- Для nginx и mysql используются готовые официальные образы с Docker Hub, а для php собирается собственный образ — нужно установить PHP-расширения для работы с MySQL.
- Переменные окружения вынесены в отдельный файл .env, а не прописаны прямо в
docker-compose.yml— так пароли не попадают в систему контроля версий, если.envдобавлен в.gitignore. - Каталог
./srcпримонтирован к nginx с флагом:ro(только чтение) — веб-серверу незачем изменять исходный код. Контейнер php получает тот же каталог на чтение и запись. - Секция volumes у mysql использует именованный том
db_data— Docker сам создаст это хранилище и будет им управлять. - Параметр user:
"1000:1000"запускает процессы PHP от имени обычного пользователя, а не root — иначе файлы, которые создаёт приложение в примонтированном каталоге./src, окажутся недоступны вам на хосте. Значения1000:1000соответствуют первому созданному пользователю Ubuntu; свои узнаете командойid -u && id -g.
Отдельно стоит сказать про container_name. Если его не указывать, Compose сформирует имена автоматически (например, myproject-php-1), и это никак не помешает контейнерам обращаться друг к другу по имени сервиса. Явное имя, как в примере выше, упрощает чтение логов и команд на этапе обучения.
.env — переменные окружения
В .env задаются имя базы, пользователь и пароли, которые подставляются в docker-compose.yml через синтаксис ${ИМЯ_ПЕРЕМЕННОЙ}. Нужно прописать:
DB_NAME=myproject
DB_USER=myproject_user
DB_PASSWORD=strong_password_here
DB_ROOT_PASSWORD=even_stronger_root_passwordИмя базы, пользователя и пароли задайте свои. Файл с реальными паролями нельзя коммитить в репозиторий — добавьте его в .gitignore. При этом .env остаётся удобным источником переменных для Compose, но не хранилищем секретов.
Если нужна серьёзная защита, пароли передают через механизм секретов той платформы, на которой вы разворачиваете стек. У Compose для этого есть секция secrets верхнего уровня: она монтирует значение внутрь контейнера как файл.
Создание Dockerfile
Официальный образ php:8.4-fpm содержит минимальный набор модулей, поэтому для работы с MySQL нужно установить расширения pdo_mysql и mysqli. Создайте файл php/Dockerfile:
FROM php:8.4-fpm
RUN docker-php-ext-install -j"$(nproc)" pdo pdo_mysql mysqli
COPY custom.ini /usr/local/etc/php/conf.d/custom.ini
WORKDIR /var/www/htmlРасширение pdo_mysql позволяет PHP обращаться к базе через PDO — стандартный интерфейс для работы с базами данных. Расширение mysqli добавлено для совместимости со старым кодом. Флаг -j"$(nproc)" ускоряет сборку, потому что использует все ядра процессора.
Файл custom.ini с дополнительными настройками копируется в образ на этапе сборки — он должен лежать рядом с Dockerfile в каталоге php/, потому что именно эта директория задана как build context. Образ автоматически собирается при первом запуске docker-compose — вызывать docker build вручную необязательно.
Настройка служб
MySQL
Для локальной разработки достаточно параметров, заданных через переменные окружения: имени базы, пользователя и пароля. Если нужно сразу наполнить базу таблицами, положите SQL-дамп в файл init.sql и подключите его как bind mount в каталог /docker-entrypoint-initdb.d. Контейнер выполнит все скрипты из этой папки при первом запуске, пока том с данными ещё пуст. Файл init.sql может выглядеть так:
В отличие от Nginx и PHP, отдельный конфигурационный файл для MySQL в этом сценарии не нужен — init.sql используется опционально, только для наполнения базы стартовыми данными. После первой инициализации главным источником данных становится том db_data: повторный запуск с новыми значениями в .env уже не пересоздаст пользователей и пароли.
Nginx
Основная задача веб-сервера — принимать HTTP-запросы и передавать их PHP-FPM для обработки. Файл default.conf описывает виртуальный хост: корневую директорию сайта, обработку статических файлов напрямую и перенаправление PHP-файлов на сервис php через протокол FastCGI (протокол взаимодействия веб-сервера с обработчиком скриптов). Содержимое файла nginx/default.conf:
server {
listen 80;
server_name localhost;
root /var/www/html;
index index.php index.html;
location / {
try_files $uri $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;
}
location ~ /\.ht {
deny all;
}
}Главная строка — fastcgi_pass php:9000. Слово php здесь не адрес, а имя сервиса из docker-compose.yml: благодаря общей docker-сети контейнеры обращаются друг к другу по именам сервисов, как по обычным доменным именам, и Nginx находит контейнер php автоматически, без прописывания IP-адресов вручную.
PHP-FPM
PHP-FPM слушает порт 9000 внутри контейнера и обрабатывает скрипты, которые передаёт Nginx. Наружу этот порт публиковать не нужно. Для большинства задач хватает настроек по умолчанию, но иногда лимиты приходится поднимать — например, максимальный размер загружаемого файла или время выполнения скрипта. Эти значения задаются в php/custom.ini:
upload_max_filesize = 32M
post_max_size = 32M
memory_limit = 256M
max_execution_time = 120Эти параметры будут скопированы в образ при сборке (см. Dockerfile выше). Поскольку файл встроен в образ, а не подключён как volume, после изменения значений образ php нужно пересобрать командой:
docker compose up -d --build php.
Настройка файрвола (UFW) и запуск среды
В готовых шаблонах Ubuntu на облачных серверах по умолчанию включён файрвол UFW (Uncomplicated Firewall). UFW сам по себе не блокирует порты, опубликованные Docker-контейнерами — Docker вносит правила в iptables в обход UFW. Но настроить фаqрвол всё равно стоит: он защищает порты, которые не проброшены через Docker (например, SSH), и предотвращает случайную публикацию портов туда, где действие правил UFW ожидается, но не срабатывает. Сначала проверьте статус. Введите в терминале команду:
sudo ufw statusВ ответе вы увидите один из двух статусов:
inactive— файрвол сейчас выключен;active— файрвол работает.
Если статус inactive, активируйте UFW командой:
sudo ufw enableДальше откройте нужные порты для работы сайта и управления сервером:
- разрешите доступ по SSH (порт 22), чтобы не потерять связь с сервером:
sudo ufw allow 22/tcp- разрешите доступ для обычного веб-трафика (порт 80):
sudo ufw allow 80/tcp- если в будущем планируется подключение SSL-сертификата (HTTPS), сразу разрешите порт 443:
sudo ufw allow 443/tcpDocker умеет самостоятельно управлять сетевыми правилами системы в обход UFW. Если в файле docker-compose.yml вы пропишете проброс порта базы данных для внешнего мира (например, "3306:3306"), Docker откроет порт 3306 для всего интернета, даже если UFW включён.
Вывод простой: не публикуйте служебные порты без необходимости. В нашем docker-compose.yml у mysql секции ports нет вовсе. База данных остаётся изолированной внутри сети app_network и доступна только для контейнера с PHP. Если доступ к базе с рабочего компьютера всё-таки нужен — через DBeaver, DataGrip или другой клиент, — привязывайте порт к локальному адресу: "127.0.0.1:3306:3306". В этом случае подключиться к базе можно будет только изнутри самого сервера или через защищённый SSH-туннель.
Запуск контейнеров
Перед первым запуском стоит проверить итоговую конфигурацию — так видно, как Compose разобрал docker-compose.yml и переменные из .env. Понадобятся три команды:
docker compose config Выведет собранную конфигурацию целиком, без запуска сервисов. Если в выводе нет ошибок и все значения (образы, порты, переменные) подставились верно, можно двигаться дальше.
docker compose up -d --build Эта команда, запущенная в корне проекта, поднимает стек. Флаг --build пересобирает образ php, если менялся Dockerfile, а -d переводит контейнеры в фоновый режим. При первом запуске Docker скачает образы nginx и mysql, соберёт образ php и создаст сеть и volumes, описанные в docker-compose.yml.
docker compose ps Показывает состояние контейнеров. Статус Up рядом с каждым сервисом означает, что запуск прошёл успешно.
Если вместо Up вы видите Restarting или Exited, контейнер завершается с ошибкой сразу после старта — смотрите раздел «Частые проблемы» ниже.
Изменение настроек среды при ошибках
- Если изменили файл, подключённый в контейнер через
bind mount(например,default.confдля Nginx), достаточно перезапустить соответствующий контейнер:
-- docker compose restart nginx- Если изменили
.envи эти переменные используются в конфигурации Compose, контейнер следует пересоздать, чтобы он получил новые значения:
docker compose up -d nginxКоманда docker compose restart изменения .env не применяет.
- Если изменили Dockerfile или файл
custom.ini, который копируется в образ при сборке, образ нужно пересобрать и пересоздать контейнер:
docker compose up -d --build php- С MySQL есть отдельный нюанс: если том с данными уже создан и инициализирован, изменение пароля в
.envне изменит пароль существующего пользователя. Начальные данные пользователя и пароль применяются только при первой инициализации пустого тома. Чтобы изменить пароль существующему пользователю, выполните SQL-командуALTER USERвнутри контейнера. Удалять том для этого не стоит: это позволит сохранить остальные данные базы.
Тестирование стека и проверка подключения к MySQL
Чтобы убедиться, что Nginx правильно передаёт запросы в PHP-FPM, а PHP успешно связывается с контейнером MySQL, подготовим исходный код приложения в каталоге ./src.
Файл src/config.php
Считывает переменные окружения и устанавливает безопасное PDO-соединение с базой данных:
PHP
<?php
$host = getenv('DB_HOST') ?: 'mysql';
$db = getenv('DB_NAME') ?: 'myproject';
$user = getenv('DB_USER') ?: 'myproject_user';
$pass = getenv('DB_PASSWORD') ?: 'strong_password_here';
try {
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (\PDOException $e) {
throw new \PDOException($e->getMessage(), (int)$e->getCode());
}
Код можно скопировать полностью, но не забывайте подставлять свои значения HOST, NAME, USER и PASSWORD.
Файл src/index.php
Этот файл обеспечивает главную точку входа. Он запрашивает данные из MySQL и выводит список всех созданных таблиц:
PHP
<?php
require_once 'config.php';
echo "<h1>LEMP Stack успешно запущен!</h1>";
echo "<p>Подключение к MySQL установлено успешно.</p>";
$stmt = $pdo->query("SHOW TABLES");
$tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo "<h3>Таблицы в базе данных:</h3><ul>";
foreach ($tables as $table) {
echo "<li>" . htmlspecialchars($table) . "</li>";
}
echo "</ul>";После наполнения файлов не забывайте сохранять данные (Ctrl+S в VSC). Откройте в браузере http://localhost. При правильной настройке появится страница с приветствием и списком таблиц (включая таблицу users, созданную через init.sql). Наличие такого сообщения подтверждает полную работоспособность всей цепочки: Nginx → PHP-FPM → MySQL.
После обеих проверок оба файла (info.php и check-db.php) стоит удалить — они не должны оставаться на продакшен-сервере.
Дополнительно стоит через терминал проверить статус контейнеров, ответ веб-сервера и логи PHP-FPM после запуска.
Частые проблемы и их решения
При первом запуске связки Nginx + PHP-FPM + MySQL проблемы чаще всего возникают из-за сетевых настроек, неправильных имён сервисов, занятых портов или особенностей инициализации MySQL. При диагностике сначала надо смотреть состояние контейнеров и логи, а уже потом менять конфигурацию.
502 Bad Gateway
Ошибка означает, что Nginx не может подключиться к PHP-FPM. Директива fastcgi_pass php:9000 отправляет запрос к сервису php в Docker-сети на порт 9000, и если ответа нет, виновата одна из трех причин.
Первая: контейнер php не запустился. Проверьте его состояние и логи:
docker compose ps
docker compose logs php
docker compose logs nginxВторая причина — неправильное имя сервиса или порт в fastcgi_pass. Например, «localhost:9000» здесь использовать нельзя: внутри контейнера Nginx localhost указывает на сам Nginx, а не на PHP.
Третья причина — PHP-FPM ещё не успел запуститься. Обычная директива depends_on задаёт только порядок старта и не ждёт, пока сервис внутри контейнера будет готов принимать соединения. Именно поэтому у mysql в нашем файле прописан healthcheck, а php ждёт его через condition: service_healthy — но сам php ничем подобным не прикрыт, и Nginx может обратиться к нему раньше времени.
PHP не подключается к MySQL
В контейнере PHP адрес localhost указывает на сам PHP-контейнер. Для обращения к MySQL нужно использовать имя сервиса из compose.yml. Если база описана как:
services:
mysql:
image: mysql:8.4Тогда PHP должен обращаться к MySQL по адресу mysql:3306. В нашем compose.yml имя сервиса передаётся в переменной DB_HOST:
DB_HOST=mysqlПорт 3306 — это внутренний порт MySQL в Docker-сети. Публиковать его на хосте для связи PHP → MySQL не требуется: контейнеры взаимодействуют через внутреннюю Docker-сеть app_network.
Connection refused и Access denied
- Connection refused означает, что PHP не смог установить соединение с MySQL. Причина может быть в том, что MySQL ещё запускается, контейнер завершился с ошибкой или указан неправильный адрес.
- Access denied означает, что до MySQL приложение уже добралось, но сервер отклонил учётные данные. В этом случае нужно проверить имя пользователя, пароль и имя базы.
Посмотреть, что происходит с MySQL, можно через команду:
docker compose logs mysqlЛоги показывают, что происходило при старте контейнера, но не отвечают на вопрос «а работает ли подключение прямо сейчас». Для проверки удобнее зайти внутрь контейнера через:
docker compose execЗапрос выполняет произвольную команду в уже запущенном контейнере, в отличие от docker compose run, которая создаёт новый.
Проверьте учётные данные напрямую в MySQL. Зайдите в контейнер mysql и подключитесь под пользователем из .env:
docker compose exec mysql mysql -u ${DB_USER} -p -h localhost ${DB_NAME} Команда запросит пароль (значение DB_PASSWORD). Если консоль MySQL открылась, значит логин, пароль и имя базы верны, и проблема не в MySQL, а где-то на стороне PHP или сети. Если появилась ошибка Access denied — значит, дело именно в учётных данных, и стоит свериться с .env ещё раз. Дополнительно можно зайти под root, чтобы проверить список пользователей и их права:
docker compose exec mysql mysql -u root -pВнутри консоли выполните:
SELECT user, host FROM mysql.user; Ответ покажет, для какого host разрешено подключение конкретному пользователю — если DB_USER создан только для 'localhost', а PHP обращается извне контейнера MySQL, подключение будет отклонено, даже если пароль верный.
Если проблема не в учётных данных, стоит проверить, видит ли контейнер php контейнер mysql по имени сервиса. Зайдите внутрь php:
docker compose exec php sh И изнутри выполните:
getent hosts mysqlЕсли команда вернула IP-адрес — сеть между контейнерами работает, и имя сервиса резолвится корректно; проблема, скорее всего, в порте или в самом MySQL. Если ответа нет — контейнеры не в одной Docker-сети (стоит проверить секцию networks в docker-compose.yml для обоих сервисов) или контейнер mysql не запущен.
MySQL не принимает новый пароль из .env
Переменные MYSQL_DATABASE, MYSQL_USER и MYSQL_PASSWORD применяются при первоначальной инициализации пустого каталога данных. Если volume MySQL уже существует, изменение пароля в .env само по себе не изменит пароль существующего пользователя. В тестовом проекте базу можно пересоздать двумя командами:
docker compose down -v
docker compose up -dНо флаг -v удаляет тома вместе с данными. В продакшене так делать нельзя, пока нет резервной копии.
Ошибки 403 и 404
Если контейнеры запущены, но Nginx возвращает «404 Not Found» или «403 Forbidden», проблема часто связана с путями к файлам или правами доступа. Для исправления нужно, чтобы Nginx и PHP использовали согласованные пути. Например, если у Nginx в root указан /var/www/html, тот же каталог с кодом должен быть смонтирован и в контейнер php. При использовании bind mount дополнительно учитывайте права файлов на хосте.
Docker и Docker Compose избавляют от необходимости вручную настраивать окружение на каждой машине: один раз описанный стек одинаково поднимается что на ноутбуке разработчика, что на сервере. Связка Nginx, PHP и MySQL закрывает большинство типичных задач — от локальной разработки CMS-сайта до подготовки тестового стенда. Дальнейшим шагом может стать добавление HTTPS, подключение Redis для кеширования или настройка автоматической пересборки образов в CI/CD (непрерывная интеграция и доставка кода).