Nginx & Docker — деплой и обслуживание веб-сервисов
Практический раздел с акцентом на Nginx (веб-сервер / reverse proxy) и базой по Docker. Реальные конфиги, файлы, команды перезапуска, типовые боли и как их лечить. Ориентировано на backend-разработчика, который сам катает Laravel/PHP на VPS.
Backend-разработчик, который умеет только писать код, а деплоем занимается «кто-то другой» — ограничен. Как только приложение выходит из локалки, начинаются вопросы: почему 502, где логи, как перезапустить, почему письма не уходят, куда пропал SSL, что за 413 Payload Too Large, почему `.env` не подтянулся, где хранятся сессии, как задеплоить без даунтайма. Всё это — не «магия DevOps», а базовая эксплуатация.
| Блок | Что внутри |
|---|---|
| Nginx — основы | Что это, где лежит, как перезапустить, базовые директивы, server blocks, location. |
| Nginx — интеграция | PHP-FPM (для Laravel/PHP), статика с gzip, reverse proxy для Node/Python. |
| Nginx — продакшн | SSL/Let's Encrypt, безопасность + rate limiting, кеширование, load balancing, логи, полный Laravel-конфиг, разбор реальных ошибок. |
| Docker — база | Что такое контейнеры, команды, Dockerfile, docker compose, volumes, networks, Laravel в Docker, боли. |
| Что ещё подтянуть | systemd, SSH-deploy, cron, мониторинг, CI/CD, чек-лист боевого сервера. |
Nginx (произносится «энджин-икс») — HTTP-сервер и обратный прокси (reverse proxy). Создан в 2004 году Игорем Сысоевым для Rambler.ru как ответ на «C10k problem» — как обслужить 10 000 одновременных соединений на одном сервере, не съев всю память. С тех пор стал одним из двух главных веб-серверов интернета (второй — Apache).
В типовой архитектуре Nginx стоит первым, между интернетом и приложением:
Ключевое архитектурное отличие Nginx от старого Apache prefork — модель обработки соединений.
| Apache prefork | Nginx | |
|---|---|---|
| Модель | Процесс на соединение | Event-driven, non-blocking I/O |
| 10 000 соединений | 10 000 процессов × 10 МБ = 100 ГБ RAM | 4 worker-процесса × ~50 МБ = 200 МБ RAM |
| Как ждёт I/O | Блокирует процесс — ждёт БД/файл | Пишет в epoll, идёт обслуживать других |
| Идеально для | Тяжёлых mod_php приложений старой школы | Reverse proxy, статика, много соединений |
HTTP-сервер
Отдавать статику (HTML, CSS, JS, картинки, шрифты, видео) с диска максимально быстро. Умеет keep-alive, gzip, HTTP/2, HTTP/3 (QUIC).
Reverse proxy
Принимает запрос от клиента и передаёт бэкенду (PHP-FPM, Node.js, Python, Java). Скрывает бэкенд от интернета, добавляет заголовки, обрабатывает таймауты.
Load balancer
Распределяет запросы между несколькими экземплярами бэкенда (upstream). Round-robin, least_conn, ip_hash. Проверяет здоровье бэкендов.
SSL termination
Держит HTTPS-сертификаты, разбирает TLS, передаёт бэкенду уже расшифрованный HTTP. Бэкенд не думает про сертификаты.
Кеш
proxy_cache / fastcgi_cache — Nginx может кешировать ответы бэкенда на диск и отдавать их без обращения к PHP. На небольших проектах заменяет Varnish.
Security layer
Rate limiting (limit_req), IP-фильтрация, скрытие версий, HTTP security headers, защита от медленных клиентов (slowloris).
В 2026 году в мире Laravel/PHP-хостинга Nginx выиграл. Apache остался в 3 нишах: cPanel-хостинг (legacy), Windows-стек, и когда нужен .htaccess в shared-хостинге с mod_rewrite «на лету». Всё что новое строится — на Nginx.
# 1. Обновить индексы пакетов
sudo apt update
# 2. Поставить nginx
sudo apt install nginx -y
# 3. Автозапуск при загрузке системы
sudo systemctl enable nginx
# 4. Стартанули
sudo systemctl start nginx
# 5. Проверка версии
nginx -v # короткая версия
nginx -V # с флагами сборки: --with-http_ssl_module и т.д.
На CentOS / RHEL / AlmaLinux / Rocky: sudo dnf install nginx -y (или yum на старых). На macOS (dev): brew install nginx.
http://IP-сервера/. Должна появиться дефолтная страница «Welcome to nginx!». Не появилась — firewall не пропускает 80/443: sudo ufw allow 'Nginx Full'.
Nginx на Debian/Ubuntu раскладывает файлы по нескольким директориям — это важно знать, чтобы понимать что где искать:
/etc/nginx/
├── nginx.conf # ГЛАВНЫЙ конфиг — общие настройки, worker'ы, включает всё остальное
├── mime.types # таблица MIME-типов (.css → text/css, .png → image/png)
├── fastcgi_params # стандартные переменные для FastCGI (PHP-FPM)
├── proxy_params # стандартные заголовки для reverse proxy
├── snippets/ # переиспользуемые куски (fastcgi-php.conf, ssl-params.conf)
│ ├── fastcgi-php.conf
│ └── ssl-params.conf
├── conf.d/ # *.conf файлы, включаемые из nginx.conf через include
│ └── (по умолчанию пусто)
├── sites-available/ # ВСЕ конфиги сайтов (по одному файлу на сайт)
│ ├── default
│ ├── example.com
│ └── api.example.com
└── sites-enabled/ # СИМЛИНКИ на активные сайты из sites-available/
├── default -> ../sites-available/default
└── example.com -> ../sites-available/example.com
/var/log/nginx/ # ЛОГИ
├── access.log # все запросы
├── error.log # ошибки, критично при отладке
├── access.log.1.gz # ротированные (logrotate)
└── error.log.1.gz
/var/www/ # типичное место для сайтов (традиция, не обязательно)
├── html/ # дефолтная welcome-страница
└── example.com/ # твой Laravel-проект
└── public/ # root у Laravel всегда = public/
/usr/sbin/nginx # сам бинарник
/var/run/nginx.pid # PID главного процесса (нужен для reload)
Это Debian-специфичный паттерн (в CentOS его нет — там просто conf.d/*.conf). Идея простая:
sites-available/— хранилище всех конфигов сайтов. Даже отключённые сайты остаются здесь.sites-enabled/— активные сайты. Это только симлинки на файлы изsites-available/.nginx.confподключает черезinclude /etc/nginx/sites-enabled/*;— только то, что в enabled.
Включить сайт:
# Создать симлинк
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
# Проверить конфиг
sudo nginx -t
# Применить
sudo systemctl reload nginx
Отключить сайт (без удаления файла):
sudo rm /etc/nginx/sites-enabled/example.com
sudo systemctl reload nginx
sites-enabled/default ловит всё, что не подошло другим server_name. Оставь его, если хочешь отдавать 404 «в никуда»; удали симлинк, если первым сайтом должен быть твой.
На маке Nginx ставят через Homebrew — для локальной разработки, не для прода. Устройство там заметно другое, чем на Debian, и первые грабли начинаются сразу.
# 1. Установка
brew install nginx
# 2. Запуск как сервис (поднимется и после перезагрузки)
brew services start nginx
# 3. Статус всех brew-сервисов
brew services list
# 4. Перезапуск после правки конфига
brew services restart nginx
# 5. Проверка
nginx -v
curl http://localhost:8080
listen 8080. Если нужен именно 80 — запускай сервис через sudo brew services start nginx и правь listen в конфиге.systemctl. Вместо него launchd: brew services кладёт plist в ~/Library/LaunchAgents/homebrew.mxcl.nginx.plist. Команды systemctl status nginx, journalctl -u nginx тут просто не существуют — смотри brew services list и /opt/homebrew/var/log/nginx/error.log./opt/homebrew, на старых Intel-маках — в /usr/local. Все пути ниже даны для Apple Silicon. Свой путь всегда можно узнать командой brew --prefix.servers/ вместо пары sites-available + sites-enabled. Символьных ссылок и команды nginx_ensite тут нет — просто положи файл в servers/, и он подхватится. В конце http-блока главного конфига стоит include servers/*;..default. Homebrew хранит рядом с каждым конфигом его эталонную копию. Если доломал nginx.conf — всегда можно вернуться: cp nginx.conf.default nginx.conf. При обновлении формулы brew не перетирает твой конфиг, а обновляет только .default.| Что | Debian / Ubuntu | macOS (Homebrew, Apple Silicon) |
|---|---|---|
| Установка | apt install nginx | brew install nginx |
| Главный конфиг | /etc/nginx/nginx.conf | /opt/homebrew/etc/nginx/nginx.conf |
| Конфиги сайтов | sites-available/ + симлинк в sites-enabled/ | servers/ — без симлинков |
| Порт по умолчанию | 80 | 8080 |
| От кого работает | root → воркеры от www-data | От твоего пользователя |
| Управление | systemctl | brew services (launchd) |
| Логи | /var/log/nginx/ | /opt/homebrew/var/log/nginx/ |
| Docroot | /var/www/ | /opt/homebrew/var/www/ |
| PID | /var/run/nginx.pid | /opt/homebrew/var/run/nginx.pid |
| Бинарник | /usr/sbin/nginx | /opt/homebrew/bin/nginx |
Откуда взялось /opt
В Unix есть устоявшаяся раскладка каталогов, и Homebrew её повторяет — просто внутри своего корня.
| Каталог | Назначение | Расшифровка |
|---|---|---|
/etc | Конфигурационные файлы | От «et cetera», но прижилась трактовка editable text configuration |
/bin, /usr/bin | Исполняемые файлы | binaries |
/var | Изменяемые данные: логи, PID, кеш | variable |
/opt | Дополнительное, не системное ПО | optional |
/home, /Users | Домашние папки | — |
# Homebrew зеркалит эту структуру у себя
/opt/homebrew/etc/nginx/ # конфиги
/opt/homebrew/var/ # логи, pid, docroot
/opt/homebrew/bin/nginx # бинарник
/opt/homebrew/Cellar/ # сами версии пакетов
rm -rf /opt/homebrew, и система не пострадает./usr/local и SIP. Часто пишут, что Homebrew ушёл из /usr/local, потому что тот «защищён SIP». Это неверно: System Integrity Protection закрывает /usr, /bin, /sbin, /System, но /usr/local из-под неё явно выведен — именно чтобы туда ставили стороннее ПО. Настоящая причина другая: /usr/local остался префиксом Intel-версии Homebrew, и отдельный /opt/homebrew позволяет держать обе сборки рядом — нативную ARM и Intel под Rosetta. Плюс это свежий каталог, которым Homebrew владеет целиком, без перенастройки прав на общесистемную папку.Что такое binary и почему папка называется bin
Binary (мн. binaries, в обиходе «бинарник») — файл с данными в двоичном виде, а не в текстовом. В контексте bin/ это исполняемые файлы: программы, уже скомпилированные в машинный код конкретной платформы.
| Тип файла | file показывает | Чем является |
|---|---|---|
| Бинарник | Mach-O 64-bit executable arm64 | Машинный код под конкретный процессор. Открыть в редакторе — увидишь мусор |
| Скрипт | Bourne-Again shell script text executable | Обычный текст, который исполняет интерпретатор |
| Конфиг | ASCII text | Просто текст, никто его не запускает |
# Чем на самом деле является файл
file /opt/homebrew/Cellar/nginx/1.31.7/bin/nginx
# Mach-O 64-bit executable arm64 ← настоящий бинарник
file /opt/homebrew/bin/brew
# Bourne-Again shell script ← а brew это скрипт, не бинарник
# Первые байты выдают формат
xxd nginx | head -1
# cffa edfe ... ← магия Mach-O (macOS)
# 7f45 4c46 = ELF (Linux), 4d5a = MZ (Windows .exe)
bin/ лежит и то, и другое: nginx — скомпилированный бинарник, а brew — обычный shell-скрипт с правом запуска. Исполняемость даёт бит x в правах, а бинарность — формат содержимого. Папку назвали bin исторически, когда там действительно лежали только скомпилированные программы.bin — разные хозяева. /bin и /usr/bin принадлежат системе (на маке их защищает SIP), /usr/local/bin и /opt/homebrew/bin — стороннему ПО. Какой из них сработает, решает порядок в PATH: проверить, что реально запустится, — which nginx, а все варианты сразу — which -a nginx.Файлы *.default
Эталонные копии от установщика. Их не трогают — это «заводская комплектация».
- Сломал конфиг — сравни с
.defaultи увидишь, что именно менял:diff nginx.conf nginx.conf.default - Восстановить дефолт:
cp nginx.conf.default nginx.conf - После обновления формулы видно, что поменялось в эталоне — твой файл brew при этом не трогает
mime.types — таблица «расширение → Content-Type»
MIME (Multipurpose Internet Mail Extensions) — стандарт, объясняющий, что за файл отдаётся. Придуман для почты, прижился в вебе. Сервер сообщает тип заголовком, а браузер по нему решает, что делать.
| Content-Type | Что делает браузер |
|---|---|
text/html | Отрисовывает как HTML |
text/css | Применяет как стили |
application/javascript | Исполняет как скрипт |
image/png | Показывает картинку |
application/json | Не рисует, отдаёт JS-коду |
application/pdf | Открывает во вьювере |
# Содержимое mime.types — около 80 строк вида «тип расширения»
types {
text/html html htm shtml;
text/css css;
application/javascript js;
image/png png;
image/jpeg jpeg jpg;
application/json json;
...
}
# Подключение в nginx.conf — строки 18–19 дефолтного конфига
include mime.types;
default_type application/octet-stream;
default_type — что отдавать, если расширение неизвестно. application/octet-stream означает «бинарный поток»: браузер скачает файл, а не попытается отрисовать.
mime.types сайт ломается молча. CSS уедет как text/plain, и браузер не применит стили. JS может не выполниться — особенно при включённом X-Content-Type-Options: nosniff, который запрещает браузеру угадывать тип. Картинки начнут скачиваться вместо показа, а JSON API сломается, если отдать его как text/html.fastcgi_params, fastcgi.conf и соседи
Наборы переменных для протоколов общения с приложением:
| Файл | Протокол | Для чего |
|---|---|---|
fastcgi_params / fastcgi.conf | FastCGI | Связь с PHP-FPM |
scgi_params | SCGI | Аналог, встречается редко |
uwsgi_params | uWSGI | Python-приложения на uWSGI |
# Так Nginx передаёт контекст запроса в PHP — это и есть $_SERVER
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param HTTPS $https if_not_empty;
diff на реальной установке показывает, что fastcgi.conf — это fastcgi_params плюс:
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;То есть в
fastcgi.conf путь к PHP-скрипту уже прописан, а при include fastcgi_params его надо дописывать самому. Поэтому обычно подключают fastcgi.conf.location ~ \.php$ {
include fastcgi.conf; # SCRIPT_FILENAME уже внутри
fastcgi_pass 127.0.0.1:9000;
}
fastcgi_params без ручного SCRIPT_FILENAME. PHP-FPM не понимает, какой файл исполнять, и отдаёт пустоту.koi-utf, koi-win, win-utf
Таблицы перекодировки для директивы charset_map: KOI8-R ↔ UTF-8 ↔ Windows-1251. Позволяли отдавать сайт в UTF-8, когда файлы лежали в KOI8-R.
servers/, var/, Cellar/ и автозапуск
| Путь | Что это |
|---|---|
etc/nginx/servers/ | Твои конфиги сайтов. Подключаются строкой include servers/*; в конце http-блока — главный nginx.conf править не нужно, каждый сайт в своём файле |
servers/.keepme | Пустышка, чтобы папка существовала сразу после установки и не терялась как пустая |
var/www/ | Дефолтный docroot с welcome-страницей |
var/log/nginx/access.log | Все запросы |
var/log/nginx/error.log | Ошибки — первое место, куда смотреть при отладке |
var/run/nginx.pid | PID мастер-процесса. По нему nginx -s reload и nginx -s stop понимают, кому слать сигнал |
Cellar/nginx/1.31.7/ | «Погреб» — сама установленная версия. Homebrew держит их несколько и переключается между ними |
bin/nginx | Симлинк на актуальную версию из Cellar. Поэтому nginx работает из любой папки |
bin/, etc/ и остальное кладутся ссылки на него. Переключение версии — это пересоздание ссылок (brew link), а не переустановка. Отсюда же brew unlink / brew link при конфликте двух версий PHP.~/Library/LaunchAgents/homebrew.mxcl.nginx.plist
launchd — механизм автозапуска macOS, аналог systemd в Linux. .plist — XML-описание: что запускать, когда и как перезапускать при падении.
| Часть имени | Что означает |
|---|---|
~/Library/LaunchAgents/ | Автозапуск для текущего пользователя. Общесистемный — в /Library/LaunchDaemons/, туда кладёт sudo brew services |
homebrew | Кто поставил |
mxcl | Ник Max Howell, создателя Homebrew |
nginx | Что запускается |
brew services start|stop|restart|list. Отсюда и разница: brew services start ставит plist в пользовательский каталог, а sudo brew services start — в системный, и только так сервис может занять порт ниже 1024.Симлинк (symbolic link, символьная ссылка) — файл-указатель на другой файл или папку. По смыслу то же, что ярлык в Windows: сам он ничего не содержит, только говорит «иди туда, там настоящий файл».
Когда ты набираешь nginx, система:
- находит
nginxвPATH— это/opt/homebrew/bin/nginx; - видит, что это симлинк;
- переходит по нему в
Cellar/nginx/1.31.7/bin/nginx; - запускает настоящий бинарник.
Создать и посмотреть
# ln -s ЦЕЛЬ ИМЯ_ССЫЛКИ (-s = symbolic)
ln -s /opt/homebrew/Cellar/nginx/1.31.7/bin/nginx /opt/homebrew/bin/nginx
# Посмотреть
ls -l /opt/homebrew/bin/nginx
# lrwxr-xr-x@ 1 observer admin 32 ... nginx -> ../Cellar/nginx/1.31.7/bin/nginx
# ↑ первая буква l = link, стрелка показывает цель
# Куда указывает, одной командой
readlink /opt/homebrew/bin/nginx
# Перезаписать существующий симлинк (-f = force)
ln -sf /новый/путь/nginx /opt/homebrew/bin/nginx
Зачем они нужны
| Задача | Как помогает симлинк |
|---|---|
| Переключение версий | В Cellar лежат 1.31.7 и 1.32.0, а bin/nginx — один симлинк. Обновился — Homebrew просто переставил его. Скрипты, вызывающие nginx, ничего не заметили |
| Короткие пути | nginx -s reload вместо /opt/homebrew/Cellar/nginx/1.31.7/bin/nginx -s reload — потому что bin/ в PATH |
| Атомарный деплой | current → releases/2026-10-16. Выкатил новый релиз рядом, переставил симлинк — переключение мгновенное, откат такой же |
| Без дублирования | Один файл, много имён — место не тратится |
Симлинк против hard link
Симлинк ln -s | Hard link ln | |
|---|---|---|
| Что это | Отдельный файл-указатель | Второе имя того же файла |
| Указывает на | Путь — то есть на строку | Сам inode — то есть на данные |
| Удалили оригинал | Ссылка ломается (dangling) | Данные живут, пока есть хоть одно имя |
| На папку | Да | Нет |
| Между файловыми системами | Да | Нет |
Сломанный симлинк
Цель удалили или переместили — ссылка остаётся, но ведёт в никуда. Такую называют dangling, «висячей».
$ ls -l /opt/homebrew/bin/nginx
lrwxr-xr-x ... nginx -> ../Cellar/nginx/1.31.7/bin/nginx # ссылка есть
$ nginx
zsh: no such file or directory: nginx # цели нет
# Починить
brew link nginx # пересоздать ссылки формулы
brew reinstall nginx # если link не помог
# Найти все битые ссылки в каталоге
find /opt/homebrew/bin -type l ! -exec test -e {} \; -print
ls файл показывает, а запуск или открытие говорит «нет такого файла». Значит указатель жив, а цель пропала.Что в Homebrew симлинк, а что нет
Распространённое заблуждение — будто симлинками сделано всё. Это не так, и разница принципиальная.
| Путь | Что это на самом деле |
|---|---|
/opt/homebrew/bin/nginx | Симлинк → ../Cellar/nginx/1.31.7/bin/nginx |
/opt/homebrew/etc/nginx/ | Обычный каталог с настоящими файлами |
/opt/homebrew/etc/nginx/nginx.conf | Обычный файл |
/opt/homebrew/var/log/nginx/ | Обычный каталог |
/opt/homebrew/var/www/ | Обычный каталог |
etc/nginx ссылкой туда — твой правленый nginx.conf улетал бы при каждом brew upgrade. Поэтому конфиги и var/ копируются один раз и живут отдельно от версий. Ровно отсюда и берутся файлы *.default: обновляется эталон, а твой рабочий файл не трогают.brew switch больше нет. Команда удалена из современного Homebrew — в старых статьях она ещё встречается. Переключение версий сейчас: brew unlink nginx && brew link nginx@1.31, либо установка версионной формулы, если она есть. Посмотреть, что лежит в Cellar: ls /opt/homebrew/Cellar/nginx/.PHP-FPM из Homebrew по умолчанию слушает TCP-порт 9000, а не unix-сокет — в отличие от Debian, где обычно /run/php/php8.3-fpm.sock. Это первое, обо что спотыкаются при переносе конфига с сервера на мак.
# Ставим PHP (придёт вместе с php-fpm) и запускаем
brew install php@8.3
brew services start php@8.3
# Проверить, на чём слушает
grep "^listen =" /opt/homebrew/etc/php/8.3/php-fpm.d/www.conf
# listen = 127.0.0.1:9000
Конфиг сайта кладём в servers/ — например /opt/homebrew/etc/nginx/servers/myapp.conf:
server {
listen 8000;
server_name localhost;
# У Laravel root ВСЕГДА указывает на public/
root /Users/username/projects/myapp/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000; # на Debian тут был бы unix-сокет
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
# Проверить синтаксис и применить
nginx -t
brew services restart nginx
~/projects он дотянется. Но если проект лежит в ~/Documents, ~/Desktop или ~/Downloads, macOS может заблокировать доступ своей защитой приватности — в логе будет Permission denied при живых правах файла. Держи локальные проекты вне этих трёх папок.nginx -t проверит синтаксис → brew services list покажет, поднят ли сервис → tail -f /opt/homebrew/var/log/nginx/error.log скажет, что именно не так. На macOS journalctl нет, лог — единственный источник правды.Соглашение: /var/www/example.com/ — корень проекта, /var/www/example.com/public/ — то, что Nginx смотрит как root (там лежит index.php). Владелец — www-data:www-data (пользователь, под которым бегает Nginx и PHP-FPM на Ubuntu/Debian).
sudo mkdir -p /var/www/example.com
sudo chown -R $USER:www-data /var/www/example.com
cd /var/www/example.com
# Клонируем проект
git clone git@github.com:youruser/yourrepo.git .
# Права: storage и bootstrap/cache должны быть writable для www-data
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
storage/logs/, storage/framework/, bootstrap/cache/. Проверяй владельца и chmod.| Команда | Что делает | Когда применять |
|---|---|---|
sudo systemctl status nginx | Статус: active/inactive/failed, PID, недавние логи | Первое что делать, если «сайт лёг» |
sudo systemctl start nginx | Запустить | После установки или ручной остановки |
sudo systemctl stop nginx | Полностью остановить | Обслуживание, обновление системы |
sudo systemctl restart nginx | Перезапустить (stop + start). Есть даунтайм ~1 сек | При проблемах, после серьёзных изменений |
sudo systemctl reload nginx | Перечитать конфиг без даунтайма: старые worker'ы дорабатывают, новые с новым конфигом | После правки конфига — всегда reload, не restart |
sudo systemctl enable nginx | Автозапуск при загрузке | Один раз после установки |
sudo systemctl disable nginx | Убрать из автозапуска | Редко |
reload отправляет SIGHUP — Nginx запускает новых worker'ов с новым конфигом, а старые дорабатывают текущие запросы и умирают. Ноль даунтайма. restart — грубее, есть короткий момент недоступности.
nginx -tВсегда проверяй конфиг ДО перезапуска:
sudo nginx -t
Что покажет:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Или с ошибкой:
nginx: [emerg] "server" directive is not allowed here in /etc/nginx/sites-enabled/example.com:15
nginx: configuration file /etc/nginx/nginx.conf test failed
systemctl reload nginx без nginx -t. Если в конфиге ошибка — reload провалится, Nginx останется на старом конфиге (это ок), но если ты сделаешь restart с битым конфигом — Nginx не запустится, сайт лёг.Идиоматический workflow:
# 1. Правим конфиг
sudo nano /etc/nginx/sites-available/example.com
# 2. Проверяем синтаксис
sudo nginx -t
# 3. Если ок — reload
sudo systemctl reload nginx
# Одной командой (сначала проверка, потом reload):
sudo nginx -t && sudo systemctl reload nginx
До systemd управляли через kill -СИГНАЛ <master-pid>. Иногда полезно понимать, что делает reload:
| Сигнал | Действие | Аналог systemd |
|---|---|---|
SIGTERM, SIGINT | Быстрый выход (обрывает текущие запросы) | systemctl stop |
SIGQUIT | Graceful shutdown — worker'ы дорабатывают запросы | — |
SIGHUP | Перечитать конфиг, graceful reload | systemctl reload |
SIGUSR1 | Переоткрыть лог-файлы (нужно после logrotate) | — |
SIGUSR2 | Обновить исполняемый файл (upgrade nginx на лету) | — |
# PID главного процесса
cat /var/run/nginx.pid
# Ручной reload через сигнал (эквивалент systemctl reload)
sudo kill -HUP $(cat /var/run/nginx.pid)
# После logrotate — переоткрыть логи
sudo kill -USR1 $(cat /var/run/nginx.pid)
Приложение не отвечает — по шагам:
sudo systemctl status nginx— что говорит systemd.active (running)— Nginx жив, дело в бэкенде.failed— Nginx не запускается.sudo journalctl -u nginx -n 50 --no-pager— последние 50 строк лога сервиса. Причина падения обычно в первой красной строке.sudo tail -f /var/log/nginx/error.log— живой поток ошибок Nginx. Показывает 502/504/permission denied/upstream timed out.sudo nginx -t— если правил конфиг перед падением, проверь синтаксис.ss -tlnp | grep -E ':80|:443'— что вообще слушает эти порты (может, Apache занял).sudo systemctl status php8.3-fpm— если Nginx работает, но 502 — умер PHP-FPM.
Nginx-конфиг иерархический — директивы живут в разных контекстах:
Директивы наследуются от родителя к потомку: gzip on в http — включает gzip во всех server внутри.
nginx.conf с пояснениями# /etc/nginx/nginx.conf — главный конфиг
# Под каким пользователем работают worker-процессы
user www-data;
# Кол-во worker'ов. auto = равно кол-ву CPU-ядер (оптимально)
worker_processes auto;
# PID главного процесса — где записан
pid /run/nginx.pid;
# Динамические модули (brotli, geoip и т.д.)
include /etc/nginx/modules-enabled/*.conf;
events {
# Сколько соединений на один worker (не то же, что клиентов!)
worker_connections 1024;
# worker_connections × worker_processes = максимум соединений сервера
# 1024 × 4 CPU = 4096 одновременных клиентов
}
http {
# ───── I/O оптимизации ─────
sendfile on; # Ядро копирует файл сразу в сокет — быстро
tcp_nopush on; # Отправить заголовок и данные одним пакетом
tcp_nodelay on; # Не буферизовать (нужно для keep-alive)
# ───── Таймауты ─────
keepalive_timeout 65; # Держать соединение открытым 65 сек
client_body_timeout 12; # Ждать тело запроса 12 сек
client_header_timeout 12; # Ждать заголовки 12 сек
send_timeout 10; # Между двумя send'ами клиенту
# ───── Размеры ─────
client_max_body_size 20M; # !! Максимальный upload (по умолчанию 1M — часто мало)
client_body_buffer_size 16K;
large_client_header_buffers 4 8k;
# ───── MIME-типы ─────
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ───── Скрыть версию Nginx (безопасность) ─────
server_tokens off;
# ───── Gzip компрессия ─────
gzip on;
gzip_vary on; # Vary: Accept-Encoding — важно для CDN/кешей
gzip_min_length 1024; # Не жать файлы меньше 1KB (накладные больше выигрыша)
gzip_comp_level 5; # 1..9. 5 — золотая середина CPU/размер
gzip_types
text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
application/vnd.ms-fontobject application/x-font-ttf font/opentype
image/svg+xml image/x-icon;
# ───── Логи ─────
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# ───── Подключаем конфиги сайтов ─────
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
| Директива | Что делает | Реальный кейс |
|---|---|---|
worker_processes auto; | Worker'ов = кол-во CPU | Оставь auto — Nginx сам определит |
worker_connections | Соединений на worker | 1024–4096. Больше = больше RAM |
keepalive_timeout | Держать соединение живым | 65с — стандарт. 5с — если под DDoS |
client_max_body_size | Макс размер запроса/upload | Ставь явно (20M/50M). Дефолт 1M — сломает загрузку файлов |
gzip on; | Сжатие ответа | Всегда on для текста, off для картинок (они уже сжаты) |
server_tokens off; | Скрыть версию nginx в Server-заголовке | Всегда off на проде — security |
error_log ... warn; | Уровень логов | warn на проде, debug для отладки (только временно!) |
client_max_body_size — самая частая забытая настройка. Дефолт 1MB. Загрузка аватара 2MB → 413 Payload Too Large. Ставь в http {} глобально (20M) или в конкретном location /upload {}.worker_processes auto: Nginx с CPU 4-cores создаст 4 worker'а. Каждый может обслуживать до worker_connections. Итого лимит одновременных клиентов = worker_processes × worker_connections. Ядро при этом должно уметь держать столько дескрипторов — проверь ulimit -n.
Server block = виртуальный хост = конфигурация одного сайта. На одном сервере с одним IP могут крутиться десятки сайтов — Nginx различает их по заголовку Host, который присылает браузер.
Каждый сайт — свой файл в /etc/nginx/sites-available/, симлинк в sites-enabled/.
# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80; # IPv6
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.htm index.php;
location / {
try_files $uri $uri/ =404;
}
}
| Директива | Что делает | Примеры |
|---|---|---|
listen | На каком порту/интерфейсе слушать | 80, 443 ssl http2, 127.0.0.1:8080, [::]:80 |
server_name | По каким доменам этот сайт отвечает | example.com www.example.com *.example.com |
root | Корневая директория файлов | /var/www/example.com/public |
index | Файл по умолчанию для директории | index.php index.html |
access_log / error_log | Свои логи для этого сайта | /var/log/nginx/example.access.log |
return | Быстрый ответ (редирект / статус) | return 301 https://$host$request_uri; |
Пришёл запрос GET /page HTTP/1.1 с Host: example.com. Nginx:
- Смотрит на порт и IP из
listen. Отбрасывает блоки, которые не слушают этот адрес. - Из оставшихся сравнивает заголовок
Hostсserver_name:- Точное совпадение (
example.com) - Затем wildcard в начале (
*.example.com) - Затем wildcard в конце (
example.*) - Затем regex
- Точное совпадение (
- Если ничего не подошло — берётся
default_server(вlisten 80 default_server;), либо первый server-блок в конфиге.
# Блок 1: HTTP → HTTPS редирект
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Один return, всё остальное — незачем
return 301 https://example.com$request_uri;
}
# Блок 2: www → non-www на HTTPS
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
# Блок 3: основной сайт
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example.com/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP — см. раздел про PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
sites-available/ либо один общий с тремя server{} — вопрос вкуса. Один файл проще для одного сайта.
location — правило для конкретного URL-пути внутри server. По одному URL может подходить несколько location'ов — Nginx выбирает один по приоритету.
| Синтаксис | Что означает | Приоритет |
|---|---|---|
location = /path | Точное совпадение URI | 1 (высший) |
location ^~ /path | Префикс, при совпадении не проверять regex дальше | 2 |
location ~ pattern | Regex, case-sensitive | 3 |
location ~* pattern | Regex, case-insensitive | 3 |
location /path | Обычный префикс (самое длинное совпадение выигрывает) | 4 |
^~ — берёт его, дальше не смотрит. Иначе идёт по regex сверху вниз, первый совпавший выигрывает. Если regex не совпал — возвращается к найденному префиксу.
server {
listen 80;
server_name example.com;
root /var/www/example.com/public;
# 1. Точное — favicon.ico. Отдать без логирования.
location = /favicon.ico {
access_log off;
log_not_found off;
}
# 2. Префикс с приоритетом ^~ — вся /admin/ через специальный handler
location ^~ /admin/ {
auth_basic "Admin area";
auth_basic_user_file /etc/nginx/.htpasswd;
}
# 3. Regex case-insensitive — статика с кешем на год
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 4. Regex — PHP-файлы через FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# 5. Дефолтный префикс — Laravel-роутинг: не файл? → index.php
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
try_files — магия Laravel-роутингаtry_files — пробует найти файлы по очереди, отдаёт первый существующий. Последний аргумент — что делать, если ничего не нашлось.
try_files $uri $uri/ /index.php?$query_string;
Разбор: для запроса GET /blog/hello-world:
$uri=/blog/hello-world— есть такой файл вroot? Нет.$uri/=/blog/hello-world/— есть такая директория? Нет.- Fallback: передать в
/index.php?$query_string— Laravel возьмёт URL изREQUEST_URIи разберётся сам через свой роутер.
try_files Nginx будет отдавать 404 на любой URL кроме /, потому что физических файлов /blog/hello-world нет — они генерируются PHP.
root vs aliasОбе задают путь на диске, но по-разному считают URL:
root /var/www/site | alias /var/www/site | |
|---|---|---|
| Полный путь | root + полный URI |
alias + URI без префикса location |
Запрос /images/photo.jpg |
ищет /var/www/site/images/photo.jpg |
для location /images/: ищет /var/www/site/photo.jpg |
| Типичное применение | Корень сайта | «Замапить» URL-путь на директорию с другим именем |
# root — доступ к /static/logo.png → /var/www/site/static/logo.png
location /static/ {
root /var/www/site;
}
# alias — тот же URL /static/logo.png → /var/www/assets/logo.png
location /static/ {
alias /var/www/assets/; # !! обязательно / в конце
}
/ в конце пути. С alias /var/www/assets; (без слэша) для запроса /static/logo.png Nginx будет искать /var/www/assetslogo.png — 404.location ~ \.php$ в одном server-блоке — Nginx возьмёт первый. Отладь через rewrite_log on; и error_log ... notice;.location ~* \.(jpg|png)$ может украсть запрос, который ты хотел отдать через /. Правило приоритета — regex > префикс, если нет ^~.rewrite ^/(.*)$ /index.php last; — устаревший синтаксис. В современном Nginx для Laravel используй try_files. rewrite оставь для сложных редиректов.location /api и location /api/ — не одно и то же. Для API обычно /api/ с трейлингом.internal; для служебных путей. Если сделал location /storage/ и оттуда через X-Accel-Redirect отдаёшь файлы — обязательно internal;, иначе клиент попадёт напрямую.PHP-FPM (FastCGI Process Manager) — отдельный сервис, который держит пул PHP-процессов и обрабатывает запросы по протоколу FastCGI. Nginx не умеет сам исполнять PHP — он передаёт запрос PHP-FPM'у, тот выполняет index.php, возвращает результат, Nginx отдаёт клиенту.
# Добавляем PPA от Ondrej (все свежие версии PHP)
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
# Ставим PHP 8.3 FPM + типовые расширения для Laravel
sudo apt install -y \
php8.3-fpm php8.3-cli \
php8.3-mysql php8.3-pgsql php8.3-sqlite3 \
php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip \
php8.3-gd php8.3-bcmath php8.3-intl php8.3-redis
# Автозапуск + старт
sudo systemctl enable php8.3-fpm
sudo systemctl start php8.3-fpm
# Проверка
sudo systemctl status php8.3-fpm
php -v
/etc/php/8.3/fpm/
├── php.ini # общий php.ini для FPM (memory_limit, upload_max_filesize)
├── php-fpm.conf # глобальные настройки FPM
└── pool.d/
└── www.conf # настройки пула по умолчанию (user, socket, pm.*)
/run/php/
└── php8.3-fpm.sock # unix-сокет, через который Nginx стучится к FPM
/var/log/php8.3-fpm.log # логи FPM (fatal ошибки PHP)
www.conf)[www]
user = www-data
group = www-data
# Слушать на unix-сокете (быстрее TCP на localhost)
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
# Управление процессами
pm = dynamic # dynamic / static / ondemand
pm.max_children = 20 # макс одновременно PHP-процессов
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500 # перезапускать процесс каждые 500 запросов (защита от утечек)
# Логи медленных запросов
slowlog = /var/log/php8.3-fpm.slow.log
request_slowlog_timeout = 5s
pm.max_children: (RAM_доступная − 1GB на ОС) ÷ (средний расход PHP-процесса, обычно 40–80MB). На сервере с 2GB RAM = (2048 − 1024) ÷ 60 ≈ 17 процессов. Если поставить больше — сервер начнёт свопиться при пиках.
Через unix-сокет (рекомендуется, если Nginx и FPM на одной машине):
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Через TCP (когда FPM на другом сервере):
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass 127.0.0.1:9000;
}
Содержимое snippets/fastcgi-php.conf — стандартный шаблон Ubuntu:
# Regex splits PATH_INFO from SCRIPT_FILENAME
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
# Проверка что файл существует ДО передачи в FPM
try_files $fastcgi_script_name =404;
# Передаваемые FastCGI-параметры
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
include fastcgi_params;
server {
listen 80;
server_name example.com;
root /var/www/example.com/public;
index index.php;
client_max_body_size 20M;
charset utf-8;
# Все URL Laravel: файл нет → отдать в index.php
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# favicon и robots — молча, без логов
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# PHP через FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 300; # для долгих операций (импорт CSV и т.п.)
}
# Запретить доступ к .htaccess и любым скрытым файлам
location ~ /\.(?!well-known).* {
deny all;
}
error_log /var/log/nginx/example.com.error.log;
access_log /var/log/nginx/example.com.access.log;
}
502 Bad Gateway. В 90% случаев PHP-FPM не запущен: sudo systemctl status php8.3-fpm. Реже — Nginx стучится не по тому сокету/порту.connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied). Nginx (пользователь www-data) не может читать сокет. Проверь listen.owner / listen.group в www.conf.504 Gateway Timeout. PHP работает дольше fastcgi_read_timeout (по умолчанию 60с). Увеличь для тяжёлых операций либо унеси в очередь (Laravel Queue)./etc/php/8.3/cli/php.ini, а FPM читает /etc/php/8.3/fpm/php.ini. И после правки обязательно sudo systemctl reload php8.3-fpm.fastcgi_pass может указывать не на ту. Проверь ls /run/php/.1.
sudo systemctl status php8.3-fpm
2.
sudo tail -f /var/log/php8.3-fpm.log
3.
sudo tail -f /var/log/nginx/error.log
4.
ls -la /run/php/php8.3-fpm.sock (владелец www-data?)
Оба называются «прокси» и оба пересылают трафик, но стоят по разные стороны и решают противоположные задачи.
| Forward proxy | Reverse proxy | |
|---|---|---|
| Чью сторону представляет | Клиента | Сервер |
| Где стоит | Рядом с клиентом | Перед бэкендом |
| Кто его настраивает | Сам клиент | Владелец сервера |
| Кто о нём знает | Клиент знает, сервер — нет | Сервер знает, клиент — нет |
| Кого скрывает | Клиента | Сервер |
| Направление | Изнутри наружу | Снаружи внутрь |
| Типичное применение | Корпоративный фильтр, обход блокировок, кеш исходящих запросов, анонимность | Терминация TLS, балансировка, отдача статики, защита бэкенда, единая точка входа |
| Примеры | Squid, корпоративный HTTP-прокси, VPN-шлюз | Nginx, HAProxy, Traefik, Cloudflare |
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
# Без этих заголовков бэкенд увидит вместо клиента сам Nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
proxy_pass меняет поведение. proxy_pass http://app:3000; передаёт URI как есть. proxy_pass http://app:3000/; — со слешем — отрезает совпавшую часть location. Для location /api/ запрос /api/users уйдёт как /users. Перепутал — получишь 404 на ровном месте.После проксирования бэкенд видит TCP-соединение от Nginx. Настоящий IP, хост и схему нужно передать явно.
| Заголовок | Что несёт |
|---|---|
Host | Исходный домен. Без него бэкенд сгенерирует ссылки на 127.0.0.1 |
X-Real-IP | IP клиента одним значением |
X-Forwarded-For | Цепочка IP через запятую; $proxy_add_x_forwarded_for дописывает текущий к уже имеющимся |
X-Forwarded-Proto | http или https. Без него Laravel за TLS-терминацией будет строить ссылки по http и ломать редиректы |
X-Forwarded-For сам и подделать IP. В Laravel за это отвечает TrustProxies: пока прокси не перечислен как доверенный, фреймворк эти заголовки игнорирует — и это правильное поведение по умолчанию.upstream backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
# Держать соединения открытыми — экономит TCP-рукопожатия
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
# keepalive к upstream работает только с HTTP/1.1 и пустым Connection
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
proxy_http_version 1.1 обязателен. По умолчанию Nginx ходит к upstream по HTTP/1.0, где keep-alive не поддерживается — директива keepalive 32 в этом случае не даёт ничего. А Connection "" нужен, чтобы не ушёл унаследованный Connection: close.WebSocket начинается как обычный HTTP-запрос с просьбой сменить протокол. Nginx должен эту просьбу пропустить, иначе соединение оборвётся на рукопожатии.
location /ws {
proxy_pass http://127.0.0.1:6001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Иначе Nginx разорвёт простаивающий сокет через 60 секунд
proxy_read_timeout 3600s;
}
proxy_read_timeout — 60 секунд. Соединение без трафика Nginx закроет, и клиент увидит обрыв без видимой причины. Лечится увеличением таймаута или heartbeat-пингами со стороны приложения.| Директива | По умолчанию | За что отвечает |
|---|---|---|
proxy_connect_timeout | 60s | Установка соединения с бэкендом. Больше 75s ставить бессмысленно |
proxy_send_timeout | 60s | Передача запроса бэкенду |
proxy_read_timeout | 60s | Ожидание ответа. Именно он даёт 504 на долгих операциях |
| Ошибка | Причина |
|---|---|
| 502 Bad Gateway | Бэкенд не отвечает: не запущен, слушает другой порт, упал |
| 504 Gateway Timeout | Бэкенд жив, но не уложился в proxy_read_timeout |
| Ссылки ведут на 127.0.0.1 | Не передан Host |
| В логах один и тот же IP | Не передан X-Real-IP / X-Forwarded-For |
| Бесконечный редирект на https | Не передан X-Forwarded-Proto: приложение считает, что пришли по http |
TLS (Transport Layer Security) — криптографический протокол, который даёт три вещи:
| Свойство | Что гарантирует |
|---|---|
| Шифрование | Данные между клиентом и сервером нельзя прочитать по пути |
| Целостность | Данные нельзя незаметно подменить |
| Аутентификация | Клиент убеждается, что сервер действительно тот, за кого себя выдаёт — через TLS-сертификат |
ssl_certificate), команд и в обиходе. Когда говорят «SSL-сертификат» — имеют в виду TLS.Самая частая схема в проде: HTTPS заканчивается на reverse proxy, а дальше внутрь идёт обычный HTTP.
По шагам:
- Прокси держит сертификаты. Например для
example.com. Сертификат содержит публичный ключ и подпись удостоверяющего центра (CA). - Клиент подключается по HTTPS. Происходит TLS handshake: стороны договариваются о шифрах, клиент проверяет сертификат, обмениваются ключами.
- Прокси «разбирает» TLS — расшифровывает входящий трафик. Это и называется терминацией.
- Дальше идёт расшифрованный HTTP — обычно во внутреннюю сеть:
http://backend:3000или к PHP-FPM. - Бэкенд не думает про сертификаты. Ему не нужно настраивать HTTPS, обновлять сертификаты и следить за версиями TLS — он получает обычный HTTP-запрос.
| Зачем так делают | Что это даёт |
|---|---|
| Централизованные сертификаты | Один прокси вместо десяти бэкендов |
| Автопродление | Let's Encrypt через Nginx, Caddy, Traefik — в одном месте |
| Снижение нагрузки | Шифрование ложится на прокси, а не на приложение |
| Единая точка безопасности | Удобно вешать WAF, rate limit, логирование |
| Простота для бэкенда | Приложение слушает обычный HTTP |
| Термин | Значение |
|---|---|
| TLS termination | Прокси завершает TLS, дальше идёт HTTP |
| TLS offloading | Тот же процесс с акцентом на снятие нагрузки с бэкенда |
| SSL termination | Устаревшее название того же самого — SSL давно мёртв, термин остался |
| Re-encryption | Расшифровал от клиента → зашифровал заново до бэкенда |
| TLS passthrough | Прокси вообще не расшифровывает, пробрасывает TLS целиком на бэкенд |
http:// — отсюда бесконечные редиректы и «mixed content». Лечится заголовком X-Forwarded-Proto $scheme из раздела про reverse proxy плюс TrustProxies на стороне Laravel.# Установка (Debian/Ubuntu)
sudo apt install certbot python3-certbot-nginx -y
# Выпуск сертификата — certbot сам правит конфиг Nginx и перезагружает его
sudo certbot --nginx -d example.com -d www.example.com
# Только выпустить, конфиг не трогать
sudo certbot certonly --nginx -d example.com
# Проверить автопродление вхолостую
sudo certbot renew --dry-run
# Список выпущенных
sudo certbot certificates
certbot.timer (или cron), который дважды в сутки проверяет срок. Сертификат Let's Encrypt живёт 90 дней, обновляется примерно за 30 до конца. Проверить таймер: systemctl list-timers | grep certbot./.well-known/acme-challenge/ и забирает его снаружи. Если 80-й закрыт файрволом или весь трафик редиректится на HTTPS без исключения для этого пути — выпуск упадёт.# HTTP — только редирект на HTTPS
server {
listen 80;
server_name example.com www.example.com;
# Исключение для certbot, иначе продление сломается
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# Сертификат и приватный ключ
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Только современные версии — 1.0 и 1.1 устарели и небезопасны
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
# Кеш сессий — меньше полных handshake'ов
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling — сервер сам прикладывает ответ о валидности
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# HSTS — браузер запомнит, что сюда только по HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /var/www/example.com/public;
index index.php;
}
| Файл Let's Encrypt | Что это |
|---|---|
fullchain.pem | Сертификат сайта + промежуточные CA. Именно его указывают в ssl_certificate |
privkey.pem | Приватный ключ. Наружу не отдаётся никогда |
chain.pem | Только промежуточные CA — нужен для OCSP stapling |
cert.pem | Только сертификат сайта, без цепочки. Для Nginx не подходит |
cert.pem вместо fullchain.pem. В браузере всё будет выглядеть нормально, а мобильные приложения и curl начнут ругаться на недоверенный сертификат — без промежуточных CA цепочка не выстраивается.max-age. Выставил max-age=31536000 — браузеры год будут ходить только по HTTPS, игнорируя твой конфиг. Сломался сертификат — сайт недоступен, и «просто откатить на HTTP» не выйдет. Начинай с малого: max-age=300, убедись что всё работает, и только потом поднимай до года.includeSubDomains накрывает все поддомены разом. Включая те, где HTTPS ещё нет — какой-нибудь внутренний old.example.com просто перестанет открываться.add_header не наследуется. Если в location есть свой add_header, все заголовки из server в этом location теряются — HSTS в том числе. Поэтому и пишут always, и дублируют заголовок там, где он нужен.nginx -t для синтаксиса, openssl s_client -connect example.com:443 -servername example.com для цепочки и версии TLS, curl -sI https://example.com | grep -i strict для HSTS. Публичный аудит — SSL Labs.Rate limiting — ограничение количества запросов от одного клиента за промежуток времени. Проще: «не больше N запросов в секунду с одного IP, пользователя или API-ключа».
- не больше 5 запросов в секунду с одного IP;
- не больше 100 запросов в минуту на один API-ключ;
- не больше 10 попыток логина в минуту с одного IP.
При превышении сервер обычно возвращает 429 Too Many Requests или 503, либо задерживает запрос, либо ненадолго ставит его в очередь.
| Зачем нужен | От чего защищает |
|---|---|
| Брутфорс | Перебор паролей на форме логина |
| DDoS и флуд | Лавина запросов с одного источника |
| Скрапинг | Массовый парсинг контента |
| Перегрузка бэкенда | Один клиент не кладёт приложение целиком |
| Справедливое распределение | Ресурсы делятся между всеми клиентами |
| Контроль нагрузки на API | Предсказуемый расход квот и мощностей |
# Зона объявляется в http-блоке, один раз
limit_req_zone $binary_remote_addr zone=api:10m rate=5r/s;
server {
location /api/ {
limit_req zone=api burst=10 nodelay;
limit_req_status 429; # по умолчанию 503
proxy_pass http://backend;
}
}
| Параметр | Что означает |
|---|---|
$binary_remote_addr | Ключ, по которому считаем. Обычно IP клиента. Бинарная форма занимает меньше памяти, чем $remote_addr |
zone=api:10m | Имя зоны и объём памяти под счётчики — 10 МБ хватает примерно на 160 тысяч IP |
rate=5r/s | Не больше 5 запросов в секунду. Можно 30r/m для минутных лимитов |
burst=10 | Разрешает кратковременный всплеск — до 10 запросов сверх лимита |
nodelay | Всплеск не задерживать, а пропускать сразу в пределах burst |
limit_req_status | Код ответа при превышении. 429 честнее, чем дефолтный 503 |
burst и nodelay. Без burst лимит жёсткий: шестой запрос в ту же секунду сразу отлетает. Реальные страницы грузят десяток ресурсов пачкой, и такой лимит будет рубить обычных пользователей. burst=10 даёт очередь на 10 запросов сверх нормы. Без nodelay они исполняются с задержкой, растягиваясь под rate — пользователь ждёт. С nodelay проходят мгновенно, а место в очереди освобождается по графику. На практике почти всегда нужен burst + nodelay.$binary_remote_addr будет содержать IP самого балансировщика, и все клиенты посчитаются как один — лимит сработает сразу для всех. Нужен real_ip_module:
set_real_ip_from 10.0.0.0/8;real_ip_header X-Forwarded-For;real_ip_recursive on;Перечислять в
set_real_ip_from надо только свои доверенные прокси — иначе клиент подделает X-Forwarded-For и обойдёт лимит.limit_req | limit_conn | |
|---|---|---|
| Ограничивает | Скорость запросов | Число одновременных соединений |
| Единица | Запросов в секунду или минуту | Открытых соединений с одного ключа |
| Против чего | Брутфорс, флуд, скрапинг | Качание файлов в сто потоков, Slowloris |
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
limit_conn addr 10; # максимум 10 соединений с одного IP
limit_conn_status 429;
}
limit_req держит частоту, limit_conn — параллелизм.| Алгоритм | Как работает | Где встречается |
|---|---|---|
| Token bucket | В ведро капают токены с постоянной скоростью, запрос забирает токен. Накопленные токены дают всплеск | API-шлюзы, Laravel RateLimiter |
| Leaky bucket | Запросы наливаются в ведро, вытекают с постоянной скоростью. Переполнилось — отказ | Nginx limit_req |
| Fixed window | Счётчик на фиксированное окно: 100 запросов в минуту, на границе обнуляется | Простые реализации на Redis |
| Sliding window | Скользящее окно — учитывает хвост предыдущего периода, считает плавнее | Cloudflare, зрелые API |
# В http-блоке: убрать версию Nginx из заголовка Server и страниц ошибок
server_tokens off;
# Доступ по IP — порядок директив важен
location /admin/ {
allow 203.0.113.0/24;
allow 10.0.0.5;
deny all; # всё остальное — 403
}
# Закрыть служебные файлы
location ~ /\.(?!well-known) {
deny all;
}
server_tokens off. Заголовок меняется с Server: nginx/1.24.0 на просто Server: nginx. Полностью убрать имя без стороннего модуля нельзя. Это не защита сама по себе — атакующий всё равно определит версию по поведению — но лишает автоматические сканеры простого способа подобрать эксплойт под конкретную версию..well-known обязательно. Правило location ~ /\. закрывает всё, что начинается с точки — включая /.well-known/acme-challenge/. Продление сертификата Let's Encrypt после этого молча сломается, и узнаешь об этом через 90 дней.add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
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 Content-Security-Policy "default-src 'self'" always;
| Заголовок | От чего защищает |
|---|---|
Strict-Transport-Security | Downgrade на HTTP и перехват на первом запросе |
X-Content-Type-Options: nosniff | Браузер не угадывает тип файла — загруженная «картинка» не исполнится как скрипт |
X-Frame-Options | Clickjacking: сайт нельзя встроить в чужой iframe |
Referrer-Policy | Утечка путей и query-параметров в Referer при переходе наружу |
Content-Security-Policy | XSS: ограничивает, откуда грузятся скрипты, стили и прочее |
add_header не наследуется в location. Если внутри location есть хоть один свой add_header, все заголовки из server в этом location пропадают. Поэтому и пишут always, и дублируют набор там, где он нужен.default-src 'self' отрубит все внешние CDN, инлайновые скрипты и стили. Включать через Content-Security-Policy-Report-Only, посмотреть отчёты, и только потом переводить в боевой режим.Slowloris — атака медленными запросами. Атакующий открывает много соединений и шлёт заголовки по байту, не завершая запрос. Соединения висят, воркеры заняты, новые клиенты не обслуживаются. Трафика почти нет — обычные анти-DDoS срабатывают плохо.
client_header_timeout 10s; # сколько ждём заголовки целиком
client_body_timeout 10s; # сколько ждём тело запроса
send_timeout 10s; # пауза между записями при ответе
keepalive_timeout 15s; # дефолт 75s — слишком щедро
client_max_body_size 20m; # заодно от залива огромных файлов
limit_conn_zone $binary_remote_addr zone=slow:10m;
limit_conn slow 20;
worker_connections и не пропустить атаку дальше, к бэкенду.Самое частое заблуждение: «настроили rate limit и headers — безопасность закрыта». Nginx и приложение защищают разные вещи и работают вместе, а не вместо друг друга.
| Nginx — периметр | Приложение — логика |
|---|---|
| Отсекает флуд, брутфорс, DDoS | Аутентификация: кто ты |
| Блокирует по IP и гео | Авторизация: что тебе можно |
| Терминирует TLS | Валидация входных данных |
| Добавляет security headers | Защита от SQL-инъекций, XSS, CSRF |
| Прячет версии и внутреннюю структуру | Бизнес-правила |
| Защищает от slowloris | Сессии, роли, права |
| Режет явно мусорные запросы (WAF, ModSecurity) |
http {
server_tokens off;
client_header_timeout 10s;
client_body_timeout 10s;
keepalive_timeout 15s;
client_max_body_size 20m;
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_req_status 429;
limit_conn_status 429;
server {
limit_req zone=general burst=50 nodelay;
limit_conn addr 20;
# Логин — отдельный жёсткий лимит против брутфорса
location = /login {
limit_req zone=login burst=3 nodelay;
try_files $uri /index.php?$query_string;
}
}
}
ab -n 200 -c 20 https://example.com/api/ или siege — и смотри, сколько ответов пришло с кодом 429. Настроенный «на глаз» лимит одинаково часто оказывается и бесполезным, и рубящим живых пользователей.Уровни кэша не заменяют друг друга, а дополняют. Запрос может быть обслужен на любом из них — чем раньше, тем дешевле.
| Уровень | Что кэширует |
|---|---|
Браузерный — Cache-Control, ETag | Готовый ответ. Клиент вообще не идёт на сервер |
Nginx — proxy_cache, fastcgi_cache | Готовый HTTP-ответ бэкенда на диске. Отдаётся без обращения к PHP или Node — самый быстрый уровень после браузера |
| Приложение — Redis, Memcached, APCu | Уже не HTTP-ответ, а данные: результат запроса к БД, сериализованный объект |
| СУБД | Внутренний кэш запросов и страниц данных |
proxy_cache или fastcgi_cache — само оно не появится.# В http-блоке: где хранить и сколько памяти под ключи
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m
max_size=1g inactive=60m use_temp_path=off;
server {
location / {
proxy_cache my_cache;
proxy_cache_valid 200 301 10m; # успешные — 10 минут
proxy_cache_valid 404 1m; # ошибки — минуту
# Не отдавать кэш авторизованным
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
# Удобно видеть в ответе HIT / MISS / BYPASS
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://backend;
}
}
| Параметр | Что делает |
|---|---|
levels=1:2 | Двухуровневая раскладка по подпапкам — тысячи файлов в одной директории тормозят ФС |
keys_zone=my_cache:10m | Имя зоны и память под ключи: 10 МБ ≈ 80 тысяч записей |
max_size=1g | Потолок кэша на диске, дальше вытесняется по LRU |
inactive=60m | Не запрашивали час — удаляется, даже если срок валидности не истёк |
proxy_cache_bypass | Не читать из кэша при истинном значении |
proxy_no_cache | Не записывать в кэш. Нужны обе — по отдельности толку мало |
proxy_cache_bypass без proxy_no_cache — утечка чужих данных. Первая директива запрещает только чтение. Ответ авторизованного пользователя всё равно запишется в кэш и уедет следующему анониму с тем же URL. Ставить всегда вместе.Ключ по умолчанию — $scheme$proxy_host$request_uri. Всё, чего в ключе нет, кэш считает одинаковым.
# Для PHP-FPM напрямую, без промежуточного HTTP-бэкенда
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=php:10m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
location ~ \.php$ {
fastcgi_cache php;
fastcgi_cache_valid 200 1m; # микрокеширование
fastcgi_cache_bypass $cookie_laravel_session;
fastcgi_no_cache $cookie_laravel_session;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
$request_method в ключе. Тогда ответ на GET и POST по одному URL попадёт в одну ячейку. По умолчанию Nginx кэширует только GET и HEAD, но стоит добавить proxy_cache_methods — и такой ключ выстрелит.| Что кэшируем | Где лучше |
|---|---|
| Статика, картинки, публичные страницы | Nginx proxy_cache |
| Персонализированные страницы | В Nginx не кэшировать вообще — только в приложении |
| Результаты тяжёлых SQL-запросов | Redis или Memcached |
| API-ответы, одинаковые для всех | Nginx proxy_cache |
| API-ответы, зависящие от пользователя | Redis с ключом по user_id |
Бесплатного способа «сбросить один URL» в открытом Nginx нет — директива proxy_cache_purge есть только в коммерческом Nginx Plus. В open source три варианта:
| Способ | Как |
|---|---|
| Короткий TTL | Не инвалидировать вовсе: proxy_cache_valid 200 30s. Самый надёжный подход |
| Удалить файл | Путь считается как md5 от ключа. Найти и удалить вручную или скриптом |
Модуль ngx_cache_purge | Сторонний модуль, даёт PURGE-запросы. Требует пересборки Nginx |
| Снести всё | rm -rf /var/cache/nginx/* и nginx -s reload. Грубо, но работает |
X-Cache-Status показывает, что произошло: MISS — не было в кэше, HIT — отдано из кэша, BYPASS — запрещено читать, EXPIRED — протухло и обновлено, STALE — отдали устаревшее, пока бэкенд лежал.На небольших и средних проектах proxy_cache часто закрывает задачу полностью. Varnish мощнее и гибче — VCL, ESI, продвинутая инвалидация, — но Nginx проще по одной причине: он уже стоит как reverse proxy, и кэш включается парой директив вместо отдельного сервиса в стеке.
proxy_cache. Переходи на Varnish, когда упрёшься в то, чего Nginx не умеет — сложная логика кэширования на VCL или сборка страницы из кусков через ESI.upstream-блок, алгоритмы (round-robin, least_conn, ip_hash), weight, backup, max_fails, fail_timeout, health-чеки, sticky sessions.
502 Bad Gateway, 504 Gateway Timeout, 413 Payload Too Large, 499 Client Closed Request, upstream sent too big header, permission denied on socket, «сайт то работает, то нет», mixed content после SSL.
docker run, docker ps, docker exec, docker logs, docker stop/rm, docker pull/images/rmi, docker inspect, docker system prune.
compose up/down/logs/exec.
exec. Laravel Sail vs собственный compose.