Wordpress

WordPress в Docker: рабочий стек из Nginx, PHP-FPM и MariaDB — от локальной разработки до продакшена

17.08.2026 12 мин чтения
WordPress в Docker: контейнеры Nginx, PHP-FPM и MariaDB

Полгода назад ко мне пришёл клиент с классической проблемой: сайт на WordPress живёт на шаред-хостинге, тормозит, а при попытке включить кеш-плагин падает на 500-й ошибке. Логи хостинг отдавать не хочет, доступ по SSH есть только в «бизнес-тарифе», а PHP на сервере — 7.4, потому что «обновление сломает другие сайты». Мы сидим в одном аккаунте с десятком чужих проектов, и любая мелочь может лечь на всех.

Я тогда впервые всерьёз собрал WordPress в Docker для продакшена, а не «поиграться локально». И с тех пор не хочу возвращаться: свой стек, свои версии PHP, бэкап — это просто копия каталога и дампа базы, а перенос на другой сервер занимает минут пятнадцать.

В этой статье — рабочий стек, который я собираю для клиентов: Nginx + PHP-FPM + MariaDB в Docker Compose, без Apache, с отдельным Dockerfile для WordPress и нормальными продакшен-мелочами вроде лимитов загрузки, opcache и ротации логов. Код прикладываю целиком, его можно забрать и запустить.

Почему вообще Docker, если есть готовые хостинги и панели

Коротко: контроль. На шаред-хостинге ты не выбираешь версию PHP, не трогаешь opcache, не ставишь свои расширения и не видишь половины логов. На VPS с панелью (типа FastPanel, ISPmanager или aaPanel) уже лучше, но панель тащит за собой свои версии PHP, свои конфиги и свои сюрпризы при обновлении.

Docker даёт ровно то, что нужно разработчику:

  • Версия PHP зафиксирована в образе. Обновил тег — получил новую версию, откатил — вернулся.
  • Стек воспроизводится. Развернул локально, прогнал тесты, выкатил на прод — это одинаковое окружение.
  • Бэкап и миграция сводятся к копированию одного volume и дампа MySQL.
  • На одном сервере можно держать несколько изолированных сайтов, каждый со своим PHP и настройками.

Плата за это — надо разобраться в трёх контейнерах и не бояться консоли. Разбирается за один вечер, дальше пользуешься годами.

Кстати, по данным репозитория официального образа на Docker Hub, WordPress-образ — один из самых скачиваемых среди официальных. Только в варианте с Apache или FPM — это два разных подхода. Apache проще для старта (там уже всё в одном контейнере), но FPM + Nginx заметно легче по памяти и гибче по настройке. Дальше собираю именно FPM-вариант.

Что входит в стек

Три сервиса:

  • Nginx — отдаёт статику и проксирует PHP-запросы на FPM. Быстрее Apache на этом сценарии и проще в настройке.
  • WordPress (php-fpm) — официальный образ wordpress:php8.3-fpm-alpine, плюс небольшой Dockerfile для WP-CLI и лимитов PHP.
  • MariaDB — база. Я беру MariaDB вместо MySQL: тот же протокол, но легче и с healthcheck-скриптом из коробки.

Статику и загрузки кладём в общий named volume wp_data, чтобы и WordPress, и Nginx видели одни файлы. Базу — в отдельный volume db_data. Никаких bind-mount на рабочий код напрямую в прод — это оставляет дыру для случайной перезаписи файлов.

docker-compose.yml целиком

Вот рабочий файл. Секреты выношу в .env, который в git не попадает.

services:
  db:
    image: mariadb:11.4
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 10

  wordpress:
    build: ./wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wp_data:/var/www/html
    depends_on:
      db:
        condition: service_healthy

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - wp_data:/var/www/html:ro
      - ./certs:/etc/letsencrypt:ro
    depends_on:
      - wordpress

volumes:
  db_data:
  wp_data:

Три момента, на которые стоит смотреть внимательно:

  • depends_on с condition: service_healthy — WordPress не стартует, пока база реально не поднялась и не готова принимать соединения. Без healthcheck контейнер базы запускается, но MySQL внутри ещё секунд десять инициализируется, и WordPress в этот момент ловит «Error establishing a database connection».
  • restart: unless-stopped — контейнеры сами встают после перезагрузки сервера, но не будут бесконечно рестартоваться, если ты их осознанно остановил.
  • Порт 80/443 наружу пробрасывает только Nginx. База и WordPress в сеть наружу не торчат — они видны только внутри compose-сети. Это дефолтное поведение, но я каждый раз явно проверяю, что у db и wordpress нет блока ports:.

Свой Dockerfile: WP-CLI и лимиты PHP

Официальный образ WordPress не содержит WP-CLI. А без него мучиться с миграцией базы или сменой домена — то ещё удовольствие. Поэтому собираю тонкий образ поверх официального:

FROM wordpress:php8.3-fpm-alpine

# WP-CLI
RUN curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
    && chmod +x wp-cli.phar \
    && mv wp-cli.phar /usr/local/bin/wp

# рабочий php.ini с внятными дефолтами
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# opcache для прода
RUN docker-php-ext-install opcache

Здесь php.ini-production — это шаблон с более строгими настройками, чем дефолтный dev-вариант. Дальше поверх него можно дописывать свои значения через отдельный php.ini, который кладёшь в контейнер. Я обычно добавляю такие строки:

upload_max_filesize = 64M
post_max_size = 72M
memory_limit = 256M
max_execution_time = 120
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 0

Последняя строка — opcache.validate_timestamps = 0 — это для прода. Она говорит opcache не проверять на каждый запрос, не изменился ли файл на диске. На проде это правильно, но если оставишь её в локальной разработке, будешь недоумевать, почему правки не подхватываются, пока не перезапустишь FPM. Я один раз потратил на это полчаса, теперь держу два php.ini: для dev и для prod.

nginx.conf: правильный fastcgi_pass и статика

Сердце стека — конфиг Nginx. Главная ошибка, которую вижу в чужих гайдах: fastcgi_pass указывают на IP-адрес или на 127.0.0.1:9000. В Docker это не работает, потому что PHP-FPM живёт в другом контейнере. Передавать нужно на имя сервиса из compose:

server {
    listen 80;
    server_name example.com;

    root /var/www/html;
    index index.php;

    client_max_body_size 64m;

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

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass wordpress:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        try_files $uri =404;
    }

    location ~ /\. {
        deny all;
    }
}

Разбор по существу:

  • try_files $uri $uri/ /index.php?$args; — это стандартная красивая ссылка WordPress. Сначала пробуем отдать существующий файл, потом каталог, и только потом отдаём всё index.php, который уже сам разберётся, что это постоянная ссылка.
  • location ~ \.php$ с try_files $uri =404; — защита от передачи несуществующего файла в PHP. Без неё Nginx может отдать PHP-файл, которого нет на диске, и это классическая дыра для некоторых конфигураций.
  • Блок статики отдаёт картинки и скрипты напрямую с кешем на 30 дней, вообще не дёргая PHP. На трафике это экономит больше всего процессора.
  • location ~ /\. закрывает доступ к скрытым файлам вроде .htaccess или .env. В контейнере .env compose-файла лежит снаружи, но бережёного бог бережёт.

client_max_body_size тут 64m и должен совпадать с лимитами PHP из предыдущего раздела, иначе при загрузке большого файла ты получишь 413 от Nginx раньше, чем PHP вообще что-то скажет. Это частая причина «почему не грузится тема на 30 мегабайт».

Схема Docker-стека для WordPress: Nginx, PHP-FPM и MariaDB в отдельных контейнерах
Стек из трёх контейнеров: Nginx отдаёт статику и проксирует PHP-запросы, WordPress работает в режиме PHP-FPM, MariaDB хранит базу. Статику и загрузки связывает общий volume.

Локальная разработка тем же стеком

Отдельный плюс этого подхода — локально у меня поднимается тот же стек, что и на проде. Для этого кладу рядом с docker-compose.yml файл docker-compose.override.yml, который Docker Compose подхватывает сам. В нём меняю только то, что нужно для разработки: пробрасываю локальную папку с темой в контейнер и включаю Xdebug.

services:
  wordpress:
    volumes:
      - ./wp-content/themes/mytheme:/var/www/html/wp-content/themes/mytheme
    environment:
      WORDPRESS_DEBUG: "1"

  nginx:
    ports:
      - "8080:80"

Смысл в том, что override-файл не уходит в прод (я держу его в .gitignore), а на проде compose просто игнорирует отсутствие локальной папки. Правки в теме подхватываются мгновенно, потому что это bind-mount, а не volume, а база и остальное окружение — те же, что и в продакшене. Не нужно больше держать отдельный MAMP или OpenServer с другой версией PHP, на которой «локально всё работало, а на проде нет».

Xdebug я ставлю редко, честно: в основном хватает var_dump и логов. Но когда нужен пошаговый дебаг, добавляю в Dockerfile для dev-образа пару строк с pecl install xdebug и конфигом, и PHPStorm цепляется к контейнеру по IP из docker compose exec wordpress ip addr. Это тема для отдельной статьи, если интересно — скажите в комментариях.

Продакшен-мелочи, которые решают

Дальше — то, что отличает «запустилось» от «работает и не падает».

Бэкапы. Базу снимаю через mysqldump изнутри контейнера, файлы — это просто копия wp_data. Обе операции можно обернуть в cron. Полное восстановление — это «скопировать volume + развернуть дамп», без магии панели. Я держу скрипт бэкапа в том же репозитории, что и compose-файл, чтобы при переезде на новый сервер не вспоминать, где что лежало.

#!/usr/bin/env bash
# бэкап базы и файлов в один каталог
set -euo pipefail
BACKUP_DIR="/backups/wordpress/$(date +%F)"
mkdir -p "$BACKUP_DIR"

docker compose exec -T db mysqldump -u wordpress -p"$DB_PASSWORD" wordpress \
  | gzip > "$BACKUP_DIR/db.sql.gz"

docker run --rm -v wp_data:/data -v "$BACKUP_DIR":/backup alpine \
  tar czf /backup/files.tar.gz -C /data .

echo "Backup in $BACKUP_DIR"

Логи. По умолчанию Docker-логи растут бесконечно, если не настроить ротацию. Добавляю в /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

После этого — systemctl restart docker. Без этой настройки через полгода обнаруживаешь, что диск забит логами, а сайт лежит с «No space left on device».

Ресурсы. PHP-FPM в Alpine-образе ест меньше памяти, чем Apache-вариант, но всё равно стоит задать лимиты, чтобы один сайт не съел весь сервер. В compose добавляю блоку wordpress:

    deploy:
      resources:
        limits:
          memory: 512M

На проде с несколькими сайтами это спасает от каскада: один сайт под нагрузкой не кладёт остальные.

WP-CLI: команды, которые выручают каждый раз

WP-CLI, который мы положили в Dockerfile, — это та штука, ради которой я и городил отдельный образ. Пара команд, которые использую регулярно:

# зайти в контейнер и получить список пользователей
docker compose exec wordpress wp user list

# сменить домен после переезда (заменяет все ссылки в базе)
docker compose exec wordpress wp search-replace 'old-domain.ru' 'new-domain.ru' --all-tables --skip-columns=guid

# обновить ядро и плагины
docker compose exec wordpress wp core update
docker compose exec wordpress wp plugin update --all

# перегенерить миниатюры после смены темы
docker compose exec wordpress wp media regenerate --yes

Про search-replace отдельно: это сериализованные данные, поэтому просто так менять домен через SQL-запрос с REPLACE нельзя — сломаются массивы в опциях и метаданных. WP-CLI делает это корректно, обходя сериализацию. --skip-columns=guid нужен, чтобы не ломать RSS-фиды: GUID должен оставаться стабильным идентификатором, а не меняться при переезде.

Ещё одна штука: WP-CLI позволяет выгрузить базу без прямого доступа к MySQL извне. То есть в compose база вообще может не пробрасывать порт наружу — все операции с ней я делаю через wp db export или mysqldump изнутри. Это аккуратнее по безопасности: база не видна миру ни на каком порту.

HTTPS: Let’s Encrypt через Caddy

SSL можно навесить прямо на Nginx через certbot, но я для этого беру Caddy — он сам получает и продлевает сертификаты, и конфиг из трёх строк. Выглядит так:

example.com {
    reverse_proxy nginx:80
}

Caddy стоит перед Nginx как ещё один контейнер и берёт на себя только TLS. Он автоматом получает сертификат Let’s Encrypt и продлевает его за 30 дней до истечения. Единственное условие — порты 80 и 443 наружу принимает уже Caddy, а Nginx остаётся слушать внутри на 80.

Если хочется остаться на одном Nginx без лишнего контейнера — берёшь официальный образ certbot и делаешь веб-рут через тот же volume. Работает, но руками прописывать cron для продления и следить за перевыпуском мне надоело, поэтому Caddy.

Частые грабли

Соберу в одном месте ошибки, на которых я сам спотыкался и которые вижу у других:

  • Права на файлы. WordPress в контейнере работает от пользователя www-data (UID 82 в Alpine). Если загрузки пишутся от root из другого контейнера, потом не можешь ничего обновить из админки. Держи один volume wp_data для всего и не шари его между разными стеками с разными UID.
  • Volume вместо bind-mount для wp-content. Если примонтировать локальную папку в /var/www/html на проде, любая правка на хосте тут же меняет прод, а права и симлинки начинают жить своей жизнью. Named volume чище.
  • Забыл про WORDPRESS_DB_HOST. Имя хоста базы — это имя сервиса из compose (db), а не localhost. На localhost WordPress будет долбиться сам в себя и падать.
  • Старый PHP в теге образа. Тег wordpress:latest тянет Apache-вариант на той версии PHP, которую решили мейнтейнеры. Фиксируй конкретный тег вида php8.3-fpm-alpine, иначе однажды образ обновится и половина плагинов перестанет стартовать.
  • HTTPS за Nginx, но WordPress думает, что он на http. Если сайт отдаётся по https через прокси, в wp-config.php нужны строки про HTTP_X_FORWARDED_PROTO, иначе админка и ссылки будут уводить на http. Ставлю их всегда, когда перед WordPress есть прокси.

Стоит ли оно того

Если у тебя один маленький сайт и ты не трогаешь сервер — оставайся на шаред-хостинге или панели, не мучай себя. Docker выигрывает, когда проектов несколько, когда нужны разные версии PHP, когда ты переезжаешь между серверами или хочешь поднимать идентичную копию сайта для тестов в одну команду.

У меня с момента перехода клиентский сайт пережил два переезда на новые серверы. Каждый раз это было «скопировал volume, развернул дамп, поправил домен в WP-CLI» — и сайт поднялся за тот же день. Раньше миграция с панели на панель занимала вечер с танцами вокруг версий PHP.

Весь код из статьи — docker-compose, Dockerfile, nginx.conf и скрипт бэкапа — бери как есть и подгоняй под свои имена доменов и пароли. Если что-то не взлетит, пиши в комментарии, разберём.

Евгений Слесаренко
Автор

Евгений Слесаренко

Частный разработчик на Bitrix и WordPress. Пишу о том, как делать сайты и сервисы понятнее, сильнее визуально и спокойнее в поддержке.