Это третья статья серии «Эксплуатация VPS / DevOps-база». В первой мы поставили сервер на ноги, во второй разобрались, как на нём вообще что-то живёт и почему nginx поднимается сам после перезагрузки. До сих пор машина умела одно — пускать вас по SSH. Сейчас появляется то, ради чего всё затевалось: браузер заходит на https://example.test и ему отдаётся ваш WordPress. За раздачу отвечает nginx (читается «энджин-х» — движок веб-сервера).
Разберём nginx по-человечески и ровно в том объёме, который нужен self-hosted WordPress на одной машине. Вы узнаете: чем nginx вообще отличается от Apache и почему под WordPress чаще берут его; как устроен его конфиг (дерево блоков http → server → location — и почему порядок location решает, отдаст ли сервер картинку или PHP-код); как отдать статику напрямую, а PHP — «наверх», к PHP-FPM, через unix-сокет (и почему сокет лучше TCP-порта); как за пять минут поставить настоящий HTTPS-сертификат от Let’s Encrypt и настроить его автопродление, чтобы через три месяца сайт не протух; как зашить безопасные HTTP-заголовки (HSTS и компанию) одним блоком; и как включить сжатие, закэшировать статику и поднять лимит загружаемого файла, чтобы wp-admin не падал на «413 Request Entity Too Large» при загрузке медиа. Это самая «прикладная» статья серии: тут вы впервые увидите конфиг, который реально обслуживает живой сайт, а не абстракцию.
Термины, которые введём один раз: веб-сервер — программа, которая слушает порт 80/443, принимает HTTP-запросы браузера и что-то им отдаёт (файл, страницу, ошибку). Reverse proxy (обратный прокси) — сервер, который сам контент не производит, а перенаправляет запрос дальше, «внутрь», к настоящей программе, и отдаёт вам её ответ, как будто это он. FastCGI — протокол, по которому веб-сервер разговаривает с PHP: «вот тебе запрос, верни HTML». Upstream (вышестоящий) — тот, кому nginx пересылает запрос (наш PHP-FPM). vhost (virtual host, «виртуальный хост») — блок
server, описывающий один домен; их на одной машине может быть много. Все домены ниже условные —example.test; подставьте свои.
Часть I. Что nginx делает под WordPress и почему не Apache
WordPress физически исполняет PHP. PHP-интерпретатор сам по себе не умеет слушать порт 80 и принимать HTTP-запросы — ему нужен посредник, который примет запрос из интернета, разберёт его и передаст PHP, а потом отдаст браузеру то, что PHP сгенерировал. Этим посредником чаще всего и ставят nginx.
1. nginx против Apache: почему выбор пал на nginx
Раньше под WordPress по умолчанию ставили Apache (httpd). Apache умеет исполнять PHP внутри себя (через mod_php) и читать раскиданные по папкам файлы .htaccess, которые «настраивают» поведение на лету. Это удобно, но дорого: Apache на каждый запрос держит тяжёлый поток и перечитывает .htaccess на каждом уровне папок.
nginx устроен иначе:
- Событийная модель (event-driven). Один рабочий процесс держит десятки тысяч соединений не «по потоку на клиента», а переключаясь по готовности. На дешёвом VPS с 1 ГБ RAM (см. swap из Части V) это разница между «живёт» и «падает в пиковые часы».
- Нет
.htaccess. nginx не читает файлы.htaccessвообще — вся логика в его собственном конфиге. (Плагин, рассчитанный на Apache-RewriteRule, в nginx работать не будет; но WordPress-конфиг nginx для permalink из коробки — обычный, о нём ниже.) Если у вас был htaccess-код, есть утилита-конвертерnginx-apache-converter(наш внутренний инструмент из серии плагинов), а для правила «на будущее» — держите логику переадресаций в конфиге nginx, не во WordPress. - PHP исполняет отдельной программой (PHP-FPM), а не в себе. nginx и PHP разделены; каждый можно перезапустить/настроить независимо, и PHP не падает вместе с веб-сервером (и наоборот).
Для self-hosted WordPress на одной машине nginx — это стандарт «по умолчанию» уже лет десять. Не потому что Apache плох, а потому что на ресурсах VPS он дешевле держит нагрузку и прозрачнее управляется.
2. Что именно nginx отдаёт сам, а что отдаёт дальше
Разложим запросы к живому WordPress-сайту на два сорта:
- Статика — файлы, которые уже лежат на диске и не нужно вычислять:
/wp-content/uploads/2026/10/photo.jpg, CSS, JS, favicon. nginx читает файл с диска и отдаёт — PHP вообще не трогается. Это быстрый путь, и его мы сознательно ускоряем (Часть VI: кэш-заголовки, сжатие). - Динамика — всё, что генерирует PHP: любая страница контента,
/wp-admin/,/wp-login.php, RSS. Здесь nginx не знает, что там будет, — он отправляет запрос наверх PHP-FPM (FastCGI), ждёт сгенерированный HTML и отдаёт браузеру. Это медленный и «дорогой» путь, и на нём держится вся часть про пулы процессов (следующая статья серии — «PHP-FPM: движок под WordPress»).
Собственно весь nginx-конфиг WordPress — это научить сервер различать эти два сорта и не путать: картинку отдавать самому, а .php — отправлять наверх. Делает это блок location (Часть II–III).
3. Ставим nginx и знакомимся с его юнитом
Если ещё не стоит:
# установим nginx из apt (пакет не перезапишет наш конфиг при апгрейде, если не лезем в /lib)
sudo apt update
sudo apt install -y nginx
# включим в автозагрузку и запустим сейчас — ровно как учили в Части III про systemd
sudo systemctl enable --now nginx
# убедимся, что слушает порты 80 (и 443, когда добавим TLS)
sudo ss -tulpn | grep -E ':80|:443'Заодно закрепляем навык из Части VI: разберём, как на самом деле устроен юнит nginx:
# показать загруженный юнит nginx с учётом возможных переопределений
systemctl cat nginx.service --no-pager | sed -n '1,40p'Вы увидите там ExecStart=/usr/sbin/nginx -g 'daemon off; master_process on;' — nginx запущен как foreground-процесс, и именно поэтому Type у него простой, а не демонизированный. Если nginx лёг — лечим через systemctl status nginx и journalctl -u nginx, а не догадками.
Часть II. Дерево конфига: http → server → location
Главная ошибка новичка — думать, что nginx-конфиг это плоский список правил «сверху вниз, первое совпавшее берём». Нет. Конфиг — дерево, и поведение наследуется от родителя к потомку. Поняв дерево, вы перестаёте бояться чужих конфигов.
1. Где физически лежит конфиг в Debian/Ubuntu
В отличие от «всё в одном httpd.conf», в Debian nginx разносит конфиг по кускам, и это удобно:
/etc/nginx/
├── nginx.conf ← корень дерева, почти не трогаем
├── sites-available/ ← здесь лежат ваши конфиги сайтов (по файлу на домен)
│ └── example.test.conf
├── sites-enabled/ ← симлинки на ВКЛЮЧЁННЫЕ конфиги из sites-available
│ └── example.test.conf -> ../sites-available/example.test.conf
├── conf.d/ ← мелкие общие вставки (заголовки, лимиты)
│ └── security-headers.conf
├── snippets/ ← переиспользуемые куски (ssl-параметры, php)
│ └── fastcgi-wordpress.conf
└── mime.types, proxy_params, … ← готовые куски от пакета
Схема работы по-взрослому: пишем конфиг в sites-available/example.test.conf, включаем его симлинком в sites-enabled/ — и nginx.conf там уже имеет строку include /etc/nginx/sites-enabled/*;, которая подтягивает включённое. Выключить сайт — убрать симлинк (apt-скрипт a2ensite/a2dissite в Debian так и делает, но симлинк руками не хуже и прозрачнее).
Почему
sites-available+ симлинки, а не всё в одном/etc/nginx/nginx.conf? Потому что на этом дереве работаетinclude. Один домен — один файл. Обновился пакет nginx — ваши файлы вsites-*иconf.dне тронуты (пакет правит только/lib-дефолты и самnginx.confне перезаписывает без вашего ведома). Плюс легко «выключить» домен, не удаляя конфиг.
2. Три уровня, которые надо знать
В nginx.conf вверху есть блок http { … }. Внутри него живут server { … }, а внутри server — location { … }:
http { # уровень 1: весь веб-стек (mime, gzip, лимиты по умолчанию)
server { # уровень 2: один домен = один vhost
listen 80;
server_name example.test; # какой домен сюда относится
location / { # уровень 3: что делать с конкретным путём запроса
# ...
}
}
}
Наследование работает сверху вниз: что поставили в http — действует во всех server, пока server не перекроет; что в server — действует во всех его location. Отсюда правило: общие вещи (gzip, лимиты, версии TLS) пишем на уровне http/conf.d один раз, доменные — в server, маршрутные — в location.
3. server_name и «дефолтный» vhost
nginx принимает любой запрос на порт и решает, в какой server его отнести, по server_name (это заголовок Host: из браузера — тот домен, что в адресной строке). Если совпадения нет — запрос уходит в default server (помечен default_server на listen, или первый по порядку в конфиге). Хорошая практика — иметь «заглушку», которая ловит мусорные запросы (сканеры, обращения по IP):
# /etc/nginx/sites-available/000-default.conf — ловим всё, что не наш домен
server {
listen 80 default_server;
server_name _; # "_" = "любой не-опознанный Host"
return 444; # 444 — nginx-специальный код: закрыть соединение без ответа
}
Без такой заглушки ваш реальный server станет дефолтным, и боты, стучащиеся по IP, будут получать ваш сайт — это и лишняя нагрузка, и лишние дубли в индексах.
4. Порядок location — то, на чём ломаются WordPress-сайты
location бывают трёх видов, и nginx выбирает их не «сверху вниз», а по приоритету:
| Синтаксис | Что значит | Приоритет |
|---|---|---|
location = /path | точное совпадение | самый высокий (1) |
location ^~ /path/ | префикс, и если сработал — не идём дальше к regex | средний (2) |
location ~ ... / ~* ... | регулярка (~ — регистрозависимая, ~* — без) | ниже префиксов (3) |
location /path | обычный префикс-подстрок | самый низкий (4) |
Правило выбора nginx: берёт самое длинное совпавшее из префиксов; если это совпадение помечено = — сразу стоп; если ^~ — тоже стоп (regex не проверяются); иначе затем проверяет regex-location по порядку записи и берёт первое совпавшее regex. Отсюда живая грабля: если у вас есть location ~ \.php$ и до него в префиксе нет ^~ /uploads/, то запрос на …/uploads/malicious.jpg/real.php (комбо-трюк) может попасть в PHP-обработчик. Поэтому у WordPress uploads специально перекрывают (^~ + deny \.php), см. Часть III. Сам синтаксис таких regex-location (классы, группы, жадность, почему \. — это буквальная точка) мы подробно разбираем в Регулярные выражения на практике.
Часть III. Отдаём статику и отправляем PHP наверх: unix-сокет и fastcgi_pass
Теперь соберём рабочий server для WordPress и разберём его построчно. Вот он целиком, потом — разбор блоков.
# /etc/nginx/sites-available/example.test.conf
server {
listen 80;
listen [::]:80;
server_name example.test www.example.test;
root /var/www/example.test; # куда nginx «заходит» корнем
index index.php index.html;
access_log /var/log/nginx/example.test.access.log;
error_log /var/log/nginx/example.test.error.log warn;
# --- главный маршрут: всё, что не файл и не .php, идёт в index.php (WordPress) ---
location / {
try_files $uri $uri/ /index.php?$args;
}
# --- статику uploads отдаём сами, и запрещаем там исполнять PHP ---
location ^~ /wp-content/uploads/ {
expires 30d; # см. Часть VI
add_header Cache-Control "public";
try_files $uri =404; # нет файла — сразу 404, не гоним в PHP
}
# --- PHP отправляем наверх PHP-FPM ---
location ~ \.php$ {
try_files $uri =404; # критично: не скармливать nginx'у чужие .php-пути
include snippets/fastcgi-wordpress.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # сокет пула (фактическое имя — из php-fpm.conf)
}
# --- статические ресурсы тоже сами (без передачи в PHP) ---
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|ico|woff2?)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
# --- не отдаём наружу служебные файлы ---
location ~ /\.ht { deny all; }
}
Разберём то, что реально ломается.
1. try_files — мозг маршрутизации WordPress
Строка try_files $uri $uri/ /index.php?$args; в location / читается по-русски: «попробуй отдать файл, на который указывает путь запроса ($uri); если это папка — попробуй её индекс ($uri/); а если файла нет вообще — отдай /index.php, передав ему исходный запрос через ?$args». Это и есть эмуляция Apache-модуля mod_rewrite: WordPress живёт в одном index.php, который сам решает, какую страницу собрать по $_SERVER['REQUEST_URI']. Без try_files запрос /2026/10/post/ уткнулся бы в несуществующий файл и выдал 404, а не пост.
Важно понимать
$args: вtry_files ... /index.php?$argsэто query-параметры (?p=123&preview=trueи т.п.). Без?$argsnginx проглотит их, и WordPress потеряет параметры — половина админки и фильтров отвалится.
2. fastcgi_pass unix:…sock — почему сокет, а не TCP-порт
PHP-FPM слушает «дверь», в которую nginx стучится за ответом. Дверь бывает двух видов:
- TCP-порт —
fastcgi_pass 127.0.0.1:9000;— как окно: к нему можно подойти и снаружи. - unix-сокет —
fastcgi_pass unix:/run/php/php8.2-fpm.sock;— это файл на диске. Чтобы им воспользоваться, надо иметь права на файл; снаружи (из интернета) в него не постучаться.
Выбор сокета почти всегда правильный на одной машине: он быстрее (не нужен сетевой стек, нет round-trip через TCP) и безопаснее (не создаёшь лишнюю слушающую дверь; в Части IV с ss -tulpn вы бы увидели 127.0.0.1:9000 лишним открытым портом). Пул PHP-FPM, который создаёт этот сокет, настраиваем в следующей статье серии «PHP-FPM: движок под WordPress»; там же www.conf и pm.max_children. Имя сокета (php8.2-fpm.sock против php8.3-fpm.sock) смотрите фактическое в /etc/php/*/fpm/pool.d/ и listen = в пуле — на разных версиях разное.
3. Снеппет fastcgi-wordpress.conf — что должно попасть к PHP
Раз уж PHP-запрос уходит наверх, надо передать PHP-FPM контекст запроса (какой файл исполнять, метод, URI, заголовки). Это и есть FastCGI-параметры. Вынесем в snippets/fastcgi-wordpress.conf, чтобы не тащить простыню в каждый location:
# /etc/nginx/snippets/fastcgi-wordpress.conf
include fastcgi_params; # базовый набор из пакета (REQUEST_METHOD, QUERY_STRING и др.)
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # ← самая частая грабля
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param REQUEST_URI $request_uri;
Строка SCRIPT_FILENAME — главный источник боли. PHP-FPM без неё не знает, какой именно файл исполнить, и на «несуществующем пути» уйдёт в дефолт или в «Primary script unknown» (ошибка 502 FastCGI). Убедитесь, что $document_root = вашему root, а $fastcgi_script_name совпадает с запросом; и что cgi.fix_pathinfo=0 в php.ini (иначе PHP сам «домыслит» путь, и к вам приедет та самая combo-уязвимость с файлом в uploads).
4. try_files $uri =404; в location ~ \.php$ — и почему без него плохо
В блоке .php я намеренно поставил try_files $uri =404;. Без него запрос /wp-content/uploads/hacked.jpg/real.php дойдёт до fastcgi_pass с несуществующим файлом, и поведение зависит от cgi.fix_pathinfo и версии PHP-FPM — от «502» до исполнения hacked.jpg как PHP (реальный способ подложить веб-шелл). =404 говорит nginx: файла нет — не морочь PHP, сразу 404, даже не стучись на сокет. Дешёвая и надёжная подстраховка.
Чек-лист граблей части
- `root` в `server` не совпадает с реальным путём сайта → всё отдаётся как `404`/`502`, `SCRIPT_FILENAME` кривой.
- Забыли `?$args` в `try_files` → параметры запроса (`?preview`, `?p=`) теряются, админка и фильтры работают урывками.
- `fastcgi_pass` на TCP-порт вместо сокета → лишний слушающий порт (`ss -tulpn` его покажет) + медленнее.
- `SCRIPT_FILENAME` не задан / задан с относительным путём → «Primary script unknown», 502.
- `cgi.fix_pathinfo=1` в PHP → combo-исполнение файлов из `uploads`; ставьте `0` и держите `try_files … =404`.
- Нет `^~ /wp-content/uploads/` + `deny/404` там → боты и атака могут попасть в PHP-обработчик из-за приоритетов `location` (см. таблицу Приоритетов в Части II).
- `location ~* \.(jpg|css…)$` *перед* `location ~ \.php$`, но без `^~` — regex-конкуренция может сработать не так, как думали; проверяйте порядок.
Часть IV. HTTPS через Let’s Encrypt: ставим сертификат и забываем о нём до истечения… который сам продлится
Голый HTTP — это открытые пароли. С 2026-го браузеры помечают сайт «Не защищено» на любом логине, а поисковики ранжируют HTTPS-сайты выше. Хорошо: сертификат и его продление ставятся одной утилитой, и дальше живут сами.
1. certbot: один шаг — и конфиг HTTPS готов
certbot (бывший Let’s Encrypt client) умеет общаться с Let’s Encrypt и сам править nginx-конфиг, добавляя туда TLS и редирект HTTP→HTTPS. Ставим связку certbot + nginx-плагин:
# ставим certbot и плагин, который сам редактирует конфиги nginx
sudo apt install -y certbot python3-certbot-nginx
# проверяем, что домен уже реально указывает на IP машины (иначе Let's Encrypt вас не найдёт)
# и что порт 80 открыт (из Части IV про firewall — будущая)
# получить сертификат и настроить nginx:
sudo certbot --nginx -d example.test -d www.example.test
certbot спросит e-mail и согласие; потом создаст сертификат в /etc/letsencrypt/live/example.test/ и допишет в ваш server-блок две вещи (примерно так):
# то, что дописал certbot в server (listen 80 → добавились 443 и редирект)
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.test/fullchain.pem; # цепочка: ваш + промежуточные
ssl_certificate_key /etc/letsencrypt/live/example.test/privkey.pem; # приватный ключ (права 600, только root)
# редирект всего HTTP на HTTPS, чтобы http://example.test не светил голый трафик
return 301 https://$host$request_uri;
fullchain.pem — «полная цепочка»: ваш сертификат + промежуточные, чтобы браузер верил в корневой. Не путать с cert.pem (только ваш) — в ssl_certificate нужен именно fullchain.
2. Проверка и тест редиректа
# конфиг валиден после правок certbot?
sudo nginx -t
# сертификат реально работает и какой даты срок?
echo | openssl s_client -connect example.test:443 -servername example.test 2>/dev/null \
| openssl x509 -noout -dates
# редирект срабатывает?
curl -I http://example.test # ожидаем 301, Location: https://example.test
3. Автопродление: почему сайт не протухнет через 90 дней
Сертификаты Let’s Encrypt живут 90 дней. Продлевать вручную — гарантия забыть. certbot ставит systemd-таймер, который сам каждый день проверяет, не пора ли продлить (а пора — когда до истечения меньше 30 дней), и продлевает + systemctl reload nginx, чтобы новый сертификат вступил в силу без простоя. Проверить, что этот механизм жив, можно ровно инструментами из Части III про systemd:
# работает ли таймер автопродления
systemctl list-timers | grep -i certbot
# а вот так — «тренировочное» продление (ничего реально не меняет, но проверяет путь)
sudo certbot renew --dry-run
--dry-run — обязательная привычка после переноса/смены сервера: он проверяет, что Let’s Encrypt до вас достучится для продления, не сжигая реальные лимиты (у Let’s Encrypt жёсткие лимиты на число выпусков на домен в неделю, см. ниже в чек-листе).
4. Грабли TLS, которые реально кусаются
- Скорая помощь для WordPress после HTTPS: если в БД у вас прописаны
http://example.test-ссылки (а у self-hosted WP часто так), сайт после редиректа будет «смешанным» (mixed content: HTTPS-страница тянет HTTP-картинки). Лечится поиском-заменой URL в БД (плагин Better Search Replace илиwp search-replaceиз WP-CLI — тема не этой серии) и правкойWP_HOME/WP_SITEURLвwp-config.php. ssl_protocols/ssl_ciphers: не изобретайте крипту руками, возьмите готовый профиль (Mozilla’s modern/intermediate) вconf.d/ssl.conf— и не включайте SSLv3/TLS1.0/1.1 (сертификаты и браузеры их давно не любят).- Не просите сертификат «на всякий случай» чаще раза в день на один домен вне
--dry-run— упрётесь в rate-limit Let’s Encrypt (5 выпусков на домен за неделю). Держите автоматическое продление и не дёргайтеcertbot certonlyруками.
Часть V. Безопасные HTTP-заголовки: один include на всю машину
HTTP-заголовки — это то, что сервер возвращает браузеру до тела страницы, чтобы подсказать браузеру, как себя вести из соображений безопасности. Разложим по полкам в отдельный файл и подключим во все домены разом — и не придётся править десять server-блоков.
1. Что включаем и зачем каждый
# /etc/nginx/conf.d/security-headers.conf
# подключается в каждый server блоком 'include /etc/nginx/conf.d/security-headers.conf;'
# либо кладём прямо в http{} — тогда действует глобально (add_header наследуется)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
Пояснение каждого — и почему always:
X-Content-Type-Options: nosniff— запрещает браузеру «угадывать» тип файла (не превращать безобидную картинку в HTML/скрипт). Дёшево, полезно против некоторых XSS-трюков.X-Frame-Options: SAMEORIGIN— сайт нельзя засунуть в<iframe>на чужом домене (защита от clickjacking — когда поверх вашей кнопки «Оплатить» накладывают невидимый фрейм). ⚠️ Грабля для WordPress: если у вас в контенте встроены YouTube/iframe-виджет/карта,SAMEORIGINим не помешает (это внешний origin), но вот ваш wp-admin, встроенный куда-то как фрейм — сломается. Для WP обычно оставляютSAMEORIGIN, потому что админку во фрейм не встраивают; если встраиваете свой сайт сам себе — смотрите наframe-ancestorsв CSP вместоX-Frame-Options.Referrer-Policy— сколько вашего URL утекает на чужие сайты при переходе по ссылке (strict-origin-when-cross-origin— разумный дефолт: домен передаём, полный путь — только в origin).Permissions-Policy— заранее запретите скриптам на странице просить доступ к камере/микро/геолокации (на WordPress-блогах это не нужно почти никогда).always— обязательный флажок. Без него nginx добавляет заголовок только на ответы 200/204/301/302/307…, но не на ошибки 4xx/5xx. А ошибки тоже читают боты/атакующие.always— чтобы заголовок стоял на любом ответе. Забудетеalways— увидите на 403/500 отсутствующийnosniff, и не поймёте почему.
2. HSTS: включать обдуманно
HSTS (HTTP Strict Transport Security, «строгая транспортная безопасность») — заголовок, который говорит браузеру: «для этого домена впредь ходи ТОЛЬКО по HTTPS, и если тебе дадут ссылку на http — сам подмени на https». После первого визита браузер запомнит это на max-age секунд и для этого домена больше не попробует http.
# включать только когда точно уверены, что весь сайт всегда на HTTPS и есть валидный TLS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
⚠️ Почему «обдуманно»: HSTS действует на клиенте и надолго. Подключите на max-age=31536000 — и на год любой браузер, заходивший на сайт, не пустит его по http. Что ломает:
- Случай без валидного TLS на поддомене.
includeSubDomainsраспространяет правило и на поддомены; если у вас где-то живой поддомен без валидного сертификата (или вы тестируетеstaging.example.testна http) — HSTS его закопает на клиенте, и вы не сможете его открыть. - Потеря/просрочка сертификата на основном домене. Браузер упрётся в HSTS и не даст «обойти предупреждение» (да, даже кнопка «Продолжить» не спасёт на HSTS-домене; на
max-ageсpreloadвообще без HTTPS не зайдёте). - Залипание при миграции/тестировании. Ставьте сначала маленький
max-age(например300— 5 минут), убедитесь, что всё чисто на HTTPS, и только потом поднимайте до года.
HSTS работает только по HTTPS (на http он игнорируется). Поэтому его имеет смысл включать уже после того, как редирект на 443 в Части IV заработал.
3. Про CSP отдельно
CSP (Content-Security-Policy) — куда более строгий заголовок: разрешает браузеру грузить скрипты/стили/картинки только с указанных источников. Теоретически — мощная защита от XSS (см. XSS и CSRF); на практике на WordPress CSP почти всегда ломает админку и виджеты, потому что WordPress динамически подгружает скрипты и inline-стили, а CSP по умолчанию такое запрещает. Совет для одной машины на Kadence: не включайте CSP вслепую с default-src 'self'; если хотите — начните с Content-Security-Policy-Report-Only (режим «не блокирует, только сообщает в report-uri») и месяца полтора собирайте отчёты, прежде чем переводить в блокирующий. Это отдельная большая тема; здесь зафиксировали рамку.
Часть VI. Сжатие, кэш статики и лимит тела: три бытовые беды WordPress
Здесь то, что реально экономит трафик и чинит конкретные ошибки, с которыми сталкивается каждый self-hosted блог.
1. gzip: отдаём текст сжатым
CSS, JS, JSON и HTML — это текст; он отлично сжимается (в 3–5 раз), и браузеры умеют это сжатие принимать. Включим gzip на уровне http (в conf.d/gzip.conf):
# /etc/nginx/conf.d/gzip.conf
gzip on;
gzip_comp_level 5; # 5 — баланс CPU/размер; больше на дешёвом VPS = лишняя нагрузка
gzip_min_length 256; # файлы короче 256 байт не выгоднее сжимать
gzip_proxied any;
gzip_vary on; # заголовок Vary: Accept-Encoding — кэш/CDN различают сжатое/несжатое
gzip_types text/plain text/css application/json application/javascript text/javascript application/xml image/svg+xml;
# ВНИМАНИЕ: в gzip_types НЕ добавляем image/* (jpg/png/webp) и архивы — они уже сжаты, gzip по ним только жжёт CPU.
Проверить, что сжатие пошло:
curl -sI -H "Accept-Encoding: gzip" https://example.test/ | grep -i content-encoding
# ожидаем: content-encoding: gzip
Сжатие картинок — это не gzip. Для статики вперёд смотрим на предзаготовленные webp/avif в плагине оптимизации изображений (уже проговорено в серии про Кеширование); nginx их просто отдаёт.
2. Кэш статики: не гонять картинку заново на каждый заход
Статика, которая редко меняется (загруженные медиа, шрифты, собранные CSS/JS), должна кэшироваться браузером, чтобы повторный визит не тянул мегабайты заново. В location для статики:
# долгосрочный кэш для файлов, у которых «отпечаток» в имени или которые не меняются
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|ico|woff2?|avif)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000" always;
access_log off;
try_files $uri =404;
}
Разница и смысл заголовков:
expires 30d;— nginx сам проставит браузеру дедлайн «не спрашивай меня об этом файле 30 дней».Cache-Control: public, max-age=…— то же самое, но современнее и работает через CDN/прокси;public— можно кэшировать где угодно.immutable— можно добавить для файлов с «отпечатком» в имени (напримерapp.3f9a2c.jsот сборщика): браузер вообще не станет проверять свежесть в течениеmax-age. Для WordPress с плагинами, которые перезаписывают файлы под тем же именем,immutableопасен — возьмёт старый файл. Здесь разумноexpires 30dбезimmutable.
⚠️ Контур-ловушка для WordPress: кэшируя CSS/JS под тем же именем, вы рискуете, что после обновления плагина пользователи 30 дней будут видеть сломанный сайт из старого кэша. Поэтому либо у плагинов оптимизации есть busting (версия/хэш в имени), либо держите короткое expires для HTML и долгое — только для реально неизменяемых ресурсов (шрифты, favicon). Полноценное кэширование страниц/фрагментов (object/page cache) — это уровень приложения, см. серию про Кеширование и CDN; nginx-expires решает только про браузер.
3. client_max_body_size: почему wp-admin падает с «413»
Загрузка медиа в WordPress — это POST с телом (файл). nginx по умолчанию обрезает тело запроса на 1 МБ и отдаёт 413 Request Entity Too Large — и вы видите это как «не могу залить картинку/видео/плагин в zip». Поднимем лимит и в http, и (точечно) в server/location для медиа-эндпоинтов:
# /etc/nginx/conf.d/uploads.conf (уровень http — общий потолок)
client_max_body_size 32m; # под ваш реальный максимум загружаемых файлов
Если хотите разное для разных путей — в server WordPress можно точечно:
location = /wp-admin/upload.php # только для загрузки медиа — крупнее
{ client_max_body_size 64m; }
⚠️ Два лимита, а не один — частая путаница. client_max_body_size — это nginx. Но PHP сам по себе режет загрузку двумя настройками в php.ini: upload_max_filesize и post_max_size. Если nginx пустит 64 МБ, а upload_max_filesize=2M, — вы получите не «413», а белый экран / «файл не загружен» на уровне PHP. Правильно: ставьте лимиты согласованно — client_max_body_size (nginx) ≥ post_max_size ≥ upload_max_filesize (PHP), и не забудьте, что PHP-FPM перечитывает php.ini только при рестарте пула (systemctl restart php8.2-fpm). Тему php.ini и пулов открывает следующая статья серии — PHP-FPM: движок под WordPress.
4. Ещё два лимита, которые спасают VPS
# /etc/nginx/conf.d/limits.conf — защита от «заливания» и медленных клиентов
client_body_timeout 12s; # сколько ждать тело запроса от клиента
client_header_timeout 12s; # сколько ждать заголовки
keepalive_timeout 15s; # как долго держать соединение в простое (idle)
send_timeout 10s;
На машине с 1 ГБ RAM и без балансировки медленные/злоупотребляющие клиенты, держащие сотни соединений, выжирают воркеры nginx — эти таймауты аккуратно их обрывают. Более грубую защиту (rate-limiting по IP, GeoIP) ставим отдельно; и если захотите прикладную защиту от атак поверх nginx (правила ModSecurity/WAF) — это тема WAF и ModSecurity, не этой статьи.
Инструменты под задачу
Есть server-блок из .htaccess-эпохи (или наоборот) — перелейте директивы между nginx и Apache без ручной подгонки try_files/RewriteRule.
Конвертер Nginx ⇄ .htaccess
А если конфиг уже на nginx — проверьте его по чек-листу граблей из этой статьи: маршрутизация, FastCGI/PHP, безопасность, TLS, производительность. ИИ найдёт проблемы и предложит исправления.
ИИ найдёт грабли маршрутизации, FastCGI, TLS и безопасности по чек-листу и предложит исправления.
Чек-лист граблей этой части
- Включили gzip, забыли `gzip_vary on` → CDN/прокси начнёт отдавать сжатое тем, кто gzip не просит (битый контент в кэше).
- Добавили в `gzip_types` `image/jpeg`/`application/zip` → сжигает CPU впустую, файлы уже сжаты.
- Поставили `immutable` на CSS/JS, которые плагины перезаписывают под тем же именем → пользователи видят кривой сайт до истечения кэша.
- `client_max_body_size` подняли, а `upload_max_filesize`/`post_max_size` в PHP оставили мелкими → не «413», а белый экран; PHP-FPM перечитывает ini только после рестарта пула.
- Таймауты/`keepalive` не выставили на малой RAM → медленные клиенты исчерпывают воркеры, сайт «зависает».
- Правки в `conf.d/*` положили, но не сделали `nginx -t` → опечатка в конфиге валит nginx целиком при следующем reload/restart.
Материал написан простым языком; любой англоязычный термин (nginx, reverse proxy, FastCGI, upstream, vhost, unix-сокет, gzip, HSTS, CSP, expires, immutable) объясняется при первом употреблении. Конфиги — для Ubuntu/Debian с nginx из
apt; конкретные версии (напримерphp8.2-fpm.sockпротивphp8.3-fpm.sock, пути сокетов) сознательно условны — проверяйте фактические имена в/etc/php/*/fpm/pool.d/иlisten =пула. Домены и пути условные (example.test,/var/www/example.test); подставляйте свои. Перед любой правкой —sudo nginx -t, после —sudo systemctl reload nginx(мягкий рестарт без разрыва соединений, см. Части III проreloadvsrestart). Тонкие механизмы (HTTP/2 иlisten … http2, QUIC/HTTP/3, TLS-сешн-тикиеты, OCSP-stapling,proxy_cacheкак обратный-прокси-кэш) упомянуты, но не раскрыты — это следующий уровень, когда базовый WordPress-стек уже живёт и стабилен.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
