Полгода назад ко мне пришёл клиент с классической проблемой: сайт на 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. В контейнере.envcompose-файла лежит снаружи, но бережёного бог бережёт.
client_max_body_size тут 64m и должен совпадать с лимитами PHP из предыдущего раздела, иначе при загрузке большого файла ты получишь 413 от Nginx раньше, чем PHP вообще что-то скажет. Это частая причина «почему не грузится тема на 30 мегабайт».

Локальная разработка тем же стеком
Отдельный плюс этого подхода — локально у меня поднимается тот же стек, что и на проде. Для этого кладу рядом с 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 и скрипт бэкапа — бери как есть и подгоняй под свои имена доменов и пароли. Если что-то не взлетит, пиши в комментарии, разберём.