Тариф успешно добавлен в корзину
В корзину
url image

Развёртывание стека Nginx + PHP + MySQL в Docker

Развёртывание веб-приложения на связке 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 может выглядеть так:

Файл init.sql
Наполнение 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.
Схема стека Nginx, PHP и MySQL
Схема стека Nginx, PHP и MySQL

 

Настройка файрвола (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/tcp

Docker умеет самостоятельно управлять сетевыми правилами системы в обход 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 (непрерывная интеграция и доставка кода).

Этот материал был полезен?

Скидка новым клиентам
Закажите сервер сегодня и получите скидку на первый месяц аренды!
Наш сайт использует cookies Вы можете отключить их в настройках браузера, но это может ограничить функционал. Оставаясь на сайте, вы соглашаетесь с использованием cookies.