Nginx: веб-сервер и reverse proxy под WordPress

Это третья статья серии «Эксплуатация 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 и знакомимся с его юнитом

Если ещё не стоит:

Bash
# установим 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:

Bash
# показать загруженный юнит 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 и т.п.). Без ?$args nginx проглотит их, и 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

Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

А если конфиг уже на nginx — проверьте его по чек-листу граблей из этой статьи: маршрутизация, FastCGI/PHP, безопасность, TLS, производительность. ИИ найдёт проблемы и предложит исправления.

AI Nginx Config Auditor
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь — для пользователей лимит + 200 000 токенов.
0 / 24 000 символов

Результат аудита
Вставьте nginx-конфиг слева и нажмите «Проверить конфиг».
ИИ найдёт грабли маршрутизации, 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 про reload vs restart). Тонкие механизмы (HTTP/2 и listen … http2, QUIC/HTTP/3, TLS-сешн-тикиеты, OCSP-stapling, proxy_cache как обратный-прокси-кэш) упомянуты, но не раскрыты — это следующий уровень, когда базовый WordPress-стек уже живёт и стабилен.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.