Шпаргалка по reverse proxy на сервере, который уже настроен по статье о первичной настройке и защите: что такое reverse proxy и зачем он нужен, подготовка DNS и портов, установка Caddy, Caddyfile для статики и для приложения, автоматический HTTPS от Let’s Encrypt, проблема с логами, диагностика и fail2ban против сканеров, которые перебирают /.env и /.git. Это не экспертный гайд, а то, что я сам сделал и на чём споткнулся.
Исходные данные
Сервер после первичной настройки: Ubuntu 26.04, пользователь demo с sudo, вход по ключу, в UFW открыт только порт SSH, fail2ban с тюрьмой sshd. Два сайта:
example.com— статика (сайт-карточка и блог), готовые файлы в/var/www/example;app.example.com— приложение, слушает127.0.0.1:3000(у меня это контейнер, Docker — следующая статья).
example.com, app.example.com, you@example.com и <IP-сервера> подставляем свои.
💡 Все команды выполняем от demo, где нужен root — с sudo.
1. Что такое reverse proxy и зачем он
Reverse proxy — программа, которая стоит между интернетом и твоими сайтами. Все запросы из интернета приходят к ней на порты 80 и 443, а она по имени сайта в запросе решает, что с ним делать: отдать файлы с диска или передать запрос приложению, которое слушает только локальный адрес.
Интернет :80/:443 → Caddy (на хосте) → /var/www/example статика
→ 127.0.0.1:3000 приложение
Зачем это нужно:
- Один вход на все сайты. На сервере может быть сколько угодно сайтов и приложений, но наружу открыты только 80 и 443. Приложение слушает
127.0.0.1— loopback, адрес «только для самого сервера», — и из интернета до него не добраться физически, даже если firewall настроен с ошибкой. - HTTPS в одном месте. Сертификаты, шифрование, редирект с HTTP на HTTPS делает прокси. Приложению не нужно ничего знать про TLS.
- Единая точка контроля. Логи всех запросов, заголовки безопасности, баны сканеров — всё в одном месте, а не в каждом приложении по отдельности.
1.1. Почему Caddy, а не nginx
| Caddy | nginx | |
|---|---|---|
| HTTPS | Автоматически: сам получает и продлевает сертификаты | certbot отдельно + таймер продления |
| Конфиг на 2 сайта | ~15 строк | ~60 строк |
| HTTP/2, HTTP/3 | Из коробки | h2 настраивается, h3 требует отдельной настройки |
| Готовые рецепты в сети | Меньше | Огромная база |
| Тонкий тюнинг под нагрузку | Меньше рычагов | Больше контроля |
Для 1–5 сайтов без экзотики беру Caddy: главное — не надо помнить про продление сертификатов. nginx — когда нужна сложная маршрутизация, тонкий тюнинг или он уже знаком.
2. Подготовка до установки
Caddy при первом запуске сразу пойдёт получать сертификаты. Чтобы это прошло с первого раза, до установки должны быть готовы три вещи: DNS, открытые порты и понимание лимитов.
⚠️ Периметр должен быть готов до выпуска сертификата. Каждый выпущенный сертификат попадает в публичные логи Certificate Transparency — открытый реестр всех сертификатов, без записи в нём браузеры сертификату не доверяют. Сканеры следят за этими логами в реальном времени. У меня первый бот пришёл на новый домен через 1 секунду после выпуска сертификата, хотя домен ещё нигде не публиковался. «Пока про сайт никто не знает» не бывает — поэтому сначала первичная настройка и защита сервера, потом сертификат.
2.1. DNS указывает на сервер
Зачем: Let’s Encrypt выдаёт сертификат, только убедившись, что домен твой: он обращается к домену и проверяет, что отвечает именно твой сервер. Если DNS ещё указывает в другое место (или запись не успела разойтись), проверка провалится.
dig +short example.com A
dig +short www.example.com A
dig +short app.example.com A
dig спрашивает у DNS, какой адрес у имени. A — запись с IPv4-адресом, +short — вывести только сам адрес, без служебной информации.
- ✅ Каждая команда вернула
<IP-сервера> - ❌ Другой IP или пусто → правим DNS у регистратора и ждём, Caddy не запускаем
dig +short example.com AAAA
AAAA — запись с IPv6-адресом. Let’s Encrypt проверяет сначала по ней, если она есть. Проверяем так же каждое имя.
- ✅ Пусто (IPv6 не используешь) или IPv6-адрес твоего сервера
- ❌ Чужой IPv6 → удалить запись у регистратора или исправить на адрес сервера
⚠️ Caddy получает сертификат на каждое имя отдельно: имя, которое не резолвится, останется без сертификата и будет сыпать ошибками в лог, остальные выпустятся. Поэтому проверяем все имена до запуска.
2.2. Порты
Зачем: сейчас в UFW открыт только порт SSH. Чтобы сайт был доступен, нужно открыть веб-порты: 80 — HTTP, 443 — HTTPS.
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw allow 443/udp comment 'HTTP/3'
comment — подпись к правилу, видна в ufw status. Через полгода по ней сразу понятно, зачем порт открыт.
sudo ufw status numbered
Список правил UFW с номерами — проверяем, что новые правила на месте.
- ✅ Для каждого порта по две строки: v4 и
(v6)
⚠️ Порт 80 нужен даже для сайта только на HTTPS. Через него Let’s Encrypt проверяет, что домен твой: обращается к http://домен/.well-known/acme-challenge/… и ждёт ответа от Caddy. Закроешь 80 — сертификат не выпустится, а уже выпущенный не продлится. Заодно Caddy на 80 отвечает редиректом на HTTPS.
💡 443/udp — для HTTP/3: он работает поверх UDP (протокол QUIC), а не TCP. Caddy включает его по умолчанию. Без этого правила сайт работает, просто браузеры остаются на HTTP/2.
2.3. Let’s Encrypt и его лимиты
Let’s Encrypt — бесплатный центр сертификации. Сертификат от него браузеры считают доверенным. Получение и продление идут по протоколу ACME: программа на сервере (здесь — Caddy) сама запрашивает сертификат, проходит проверку владения доменом и получает его. Никаких ручных действий.
Чтобы его не заваливали запросами, у Let’s Encrypt есть лимиты:
| Лимит | Значение |
|---|---|
| Неудачных проверок | 5 на имя для одного аккаунта в час |
| Сертификатов на домен | 50 за 7 дней |
| Одинаковых сертификатов (тот же набор имён) | 5 за 7 дней |
Значения — по документации Let’s Encrypt на 2026-10-15.
Что это значит на практике: Caddy сам бережёт лимиты: при неудаче повторяет попытку через тестовый сервер Let’s Encrypt, потом пробует ZeroSSL, а паузы между попытками растут до суток. Поэтому, если запустить его с неготовым DNS, лимит он не исчерпает, но после исправления DNS сертификат может появиться только через несколько часов. Чтобы не ждать, DNS проверяем до запуска, а если уже запустили — после исправления делаем sudo systemctl reload caddy.
Для отладки есть тестовый центр сертификации с огромными лимитами. Его сертификаты браузер не примет, но проверить, что выпуск проходит, можно. Строку acme_ca добавляем в глобальный блок в начале Caddyfile (раздел 4.1), отдельный блок не создаём, иначе Caddy не разберёт файл:
{
email you@example.com
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Когда всё заработало — строку acme_ca убрать и сделать reload (раздел 5.1).
3. Установка
3.1. Откуда ставить
В репозитории Ubuntu 26.04 есть пакет caddy, но версии 2.6.2 — это 2022 год, без исправлений и новых возможностей за несколько лет. Поэтому подключаем официальный репозиторий Caddy (он размещён на Cloudsmith) и ставим из него. Обновления потом приходят обычным apt upgrade.
3.2. Репозиторий
Чтобы apt доверял чужому репозиторию, ему нужны две вещи: ключ, которым подписаны пакеты, и адрес репозитория.
sudo apt install debian-keyring debian-archive-keyring apt-transport-https curl
Пакеты из официальной инструкции Caddy (она общая для Debian и Ubuntu). На Ubuntu curl уже стоит, apt-transport-https у меня тоже стоял (с образа провайдера); debian-keyring и debian-archive-keyring установятся — это ключи Debian, вреда нет. apt-transport-https — пустой переходный пакет: HTTPS в apt встроен давно.
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
Скачивает GPG-ключ Caddy. apt проверяет им подпись каждого пакета: пакет, подписанный не этим ключом, не установится.
curl -1sLf:-1— только TLS 1.0 и новее,-s— без прогресс-бара,-L— идти по редиректам,-f— при ошибке сервера завершиться с ошибкой, а не сохранить страницу ошибки как ключ;gpg --dearmor— переводит ключ из текстового вида в двоичный, который понимает apt;-o …keyring.gpg— куда сохранить.
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
Скачивает описание репозитория и кладёт его в список источников apt. В описании стоит signed-by= с путём к ключу из прошлого шага — так apt знает, каким ключом проверять именно этот репозиторий. any-version вместо кодового имени релиза — репозиторий общий для всех версий Debian и Ubuntu.
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
Даёт всем право читать оба файла: apt проверяет подпись от служебного пользователя _apt — ему нужен ключ, а список источников читают и команды apt без sudo (apt policy).
3.3. Пакет
sudo apt update
Перечитать списки пакетов — теперь вместе с репозиторием Caddy.
sudo apt install caddy
⚠️ Без -y. apt может предложить удалить пакеты, которые конфликтуют с новым; с -y это произойдёт без вопроса. Поэтому перед подтверждением читаем блок The following packages will be REMOVED — у меня так однажды молча удалился UFW (подробно — в статье про Docker, раздел про iptables-persistent).
Пакет сам создаёт пользователя caddy, службу systemd и стартовый конфиг /etc/caddy/Caddyfile, который отдаёт заглушку на порту 80.
3.4. Проверка
systemctl is-active caddy && curl -I http://localhost
Служба запущена? Если да — запросить у неё заголовки ответа (-I) прямо с сервера.
- ✅
activeи в заголовкахServer: Caddy - ❌
failed→sudo journalctl -u caddy -n 30 --no-pager(последние 30 строк лога службы)
💡 Caddy работает от пользователя caddy, а не от root. Порты ниже 1024 обычно может открывать только root; Caddy это разрешает строка AmbientCapabilities= юнита systemd: CAP_NET_BIND_SERVICE — право на «привилегированные» порты без остальных прав root, CAP_NET_ADMIN — чтобы увеличить буферы UDP для HTTP/3. Если Caddy взломают, у атакующего будут права пользователя caddy, а не root.
4. Caddyfile
Caddyfile — конфиг Caddy, /etc/caddy/Caddyfile. В нём по блоку на каждый сайт: какие имена, что делать с запросами, куда писать логи. Всё про сертификаты и HTTPS Caddy делает сам — в конфиге этого нет.
4.1. Конфиг на два сайта
sudo nano /etc/caddy/Caddyfile
Файл принадлежит root, поэтому редактор — через sudo. Заменяем содержимое целиком:
{
email you@example.com
}
# статика
example.com, www.example.com {
root * /var/www/example
file_server
encode zstd gzip
log {
output file /var/log/caddy/example.log {
roll_size 10mb
roll_keep 3
}
}
}
# прокси на приложение
app.example.com {
reverse_proxy 127.0.0.1:3000
log {
output file /var/log/caddy/app.log {
roll_size 10mb
roll_keep 3
}
}
}
Разбор по блокам:
{ email … }— глобальные настройки, блок без имени в начале файла.email— адрес аккаунта у Let’s Encrypt. Писем об истечении сертификатов Let’s Encrypt больше не шлёт (прекратил с 4 июня 2025), так что за продлением следим по логам Caddy (раздел 5.2).example.com, www.example.com { … }— блок сайта. Имена через запятую обслуживаются одинаково, сертификат Caddy получает на каждое имя отдельно.root * /var/www/example+file_server— отдавать файлы из этой папки.*— для всех путей.encode zstd gzip— сжимать ответы, если браузер это умеет: страницы грузятся быстрее.reverse_proxy 127.0.0.1:3000— всё, что пришло наapp.example.com, передаётся приложению. ЗаголовкиX-Forwarded-ForиX-Forwarded-ProtoCaddy проставляет сам — по ним приложение видит реальный IP посетителя, а не127.0.0.1прокси.log { output file … }— свой файл лога на каждый сайт.roll_size 10mb+roll_keep 3— ротация: когда файл дорастёт до 10 МБ, он архивируется, хранятся три последних. Эти логи понадобятся для fail2ban (раздел 8).
Чего в конфиге нет — и это правильно:
- сертификатов — Caddy получит их сам и сам продлит;
- редиректа HTTP → HTTPS — Caddy делает его автоматически;
- HTTP/2 и HTTP/3 — включены по умолчанию.
💡 Сейчас Let’s Encrypt выдаёт сертификаты на 90 дней, а к 2028 году срок сократится до 45. Caddy продлевает заранее и сам, на настройку это не влияет.
4.2. Папка сайта и тестовая страница
Зачем: если папки из root нет или она пустая, Caddy на любой запрос ответит 404 — и по нему не понять, работает ли HTTPS. Тестовая страница сразу показывает: сертификат есть, файлы отдаются. Потом её заменит настоящий сайт (деплой из CI).
sudo mkdir -p /var/www/example
echo '<h1>example.com работает</h1>' | sudo tee /var/www/example/index.html > /dev/null
mkdir -p— создать папку вместе с родительскими; если уже есть — без ошибки;sudo tee— записать файл в папку, куда пишет только root;> /dev/null— не дублировать текст на экран.
Файлы принадлежат root, но читать их могут все (права 644) — Caddy от пользователя caddy их отдаст.
- ✅ В 5.3 HTTPS отвечает
200 - ❌
404→ нетindex.htmlв папке изroot:ls -la /var/www/example
4.3. Полезные директивы
Не нужны с первого дня, но пригодятся: редирект с www, одностраничные приложения, кэш, пароль на раздел, заголовки безопасности.
Справка: редирект www, SPA, кэш, пароль, заголовки, healthcheck
# редирект www → без www: один адрес сайта для поиска и закладок
www.example.com {
redir https://example.com{uri} permanent
}
# SPA — все несуществующие пути отдать как index.html, маршруты разбирает JS
try_files {path} /index.html
# статика с долгим кэшем в браузере (для файлов с хешем в имени)
@static path *.js *.css *.woff2 *.png *.jpg *.svg
header @static Cache-Control "public, max-age=31536000, immutable"
# вход по паролю; хеш получить командой caddy hash-password
basic_auth {
user ХЕШ_ПАРОЛЯ
}
# заголовки безопасности: только HTTPS, без угадывания типов, без встраивания в чужие фреймы
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# прокси с проверкой здоровья: Caddy сам опрашивает приложение и не шлёт запросы упавшему
reverse_proxy 127.0.0.1:3000 {
health_uri /api/health
health_interval 10s
}
Если www.example.com вынесен в отдельный блок с редиректом, из блока статики его нужно убрать.
Все директивы, кроме блока www.example.com, вписываются внутрь блока сайта. После -Server проверка в 5.3 не увидит server: Caddy — это нормально.
5. Применение
Изменения в Caddyfile не действуют, пока Caddy его не перечитает. Порядок всегда один: проверить конфиг → перечитать → убедиться, что сертификаты выпущены → проверить снаружи.
5.1. Проверка конфига и reload
Зачем проверять: опечатка в Caddyfile при перезапуске может уронить сразу все сайты. Проверка находит ошибку до применения.
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
caddy validate разбирает конфиг и проверяет его, ничего не применяя. sudo -u caddy — выполнить от пользователя caddy, а не от root.
- ✅
Valid configuration - ❌ Ошибка с номером строки → чинить, не перезагружать
⚠️ Почему от caddy, а не просто sudo. При проверке Caddy открывает файлы логов из блоков log (и создаёт их, если их нет): от root они создадутся с владельцем root, и потом сам сервис не сможет в них писать (раздел 6). От demo без sudo проверка упрётся в права на /var/log/caddy.
sudo systemctl reload caddy
reload — Caddy перечитывает конфиг без остановки: открытые соединения не рвутся, сайты не моргают.
💡 reload, а не restart: если в новом конфиге всё-таки ошибка, reload откатится и старый конфиг продолжит работать. restart в такой ситуации уронил бы оба сайта.
5.2. Выпуск сертификатов
Зачем: после reload Caddy в фоне идёт за сертификатами. Если что-то не так с DNS или портом 80, сайт не откроется по HTTPS — а узнать причину можно только из лога.
sudo journalctl -u caddy --since '2 min ago' --no-pager | grep -iE 'certificate obtained|error'
Лог службы Caddy за последние 2 минуты, только строки про выданные сертификаты и ошибки.
- ✅
certificate obtained successfullyдля каждого имени - ⚠️
creating new account because no account for configured email is known to usс полемerrorвнутри — не ошибка, уровеньinfo: при первом запуске создаётся аккаунт у Let’s Encrypt - ❌
challenge failed/timeout during connect→ DNS (раздел 2.1) или закрыт порт 80 (раздел 2.2)
5.3. Проверка снаружи
Зачем: с самого сервера всё может работать, а из интернета — нет (firewall, DNS, сертификат). Единственная честная проверка — с другой машины.
С локальной машины:
curl.exe -I https://example.com
curl.exe -I http://example.com
curl.exe — именно с .exe: в Windows PowerShell 5.1 просто curl — это псевдоним Invoke-WebRequest. -I — только заголовки ответа.
- ✅ HTTPS:
200иserver: Caddy - ✅ HTTP:
308иlocation: https://example.com/— редирект на HTTPS работает
💡 308, а не 301: оба значат «переехало навсегда», но 308 обязывает браузер повторить запрос с тем же методом и телом. Для POST-форм это критично — с 301 браузер превратил бы POST в GET, и данные формы потерялись бы.
6. Проблема: permission denied на файлы логов
На этом я застрял дольше всего, поэтому подробно.
Симптом. caddy validate пишет Valid configuration, но сервис не стартует:
Job for caddy.service failed.
open /var/log/caddy/example.log: permission denied
При этом у папки владелец правильный:
ls -ld /var/log/caddy
-d — показать саму папку, а не её содержимое.
drwxr-xr-x 2 caddy caddy 4096 /var/log/caddy
Смотрим не папку, а файлы в ней:
ls -l /var/log/caddy/
- ❌ Файлы с владельцем
root→ их создал запуск Caddy от root, вероятнее всегоsudo caddy validate(раздел 5.1). Писать в папкуcaddyможет, а в чужой файл — нет. - ✅ Владелец
caddy→ причина в другом, см. тест ниже.
Починка — вернуть файлы пользователю caddy:
sudo chown -R caddy:caddy /var/log/caddy
sudo systemctl restart caddy
systemctl is-active caddy
Здесь restart, а не reload: сервис упал, перечитывать нечего — его нужно запустить.
Диагностический тест, если владелец файлов правильный. Разделяет две причины: обычные права и ограничения самой службы.
sudo -u caddy touch /var/log/caddy/test.log && echo "РАБОТАЕТ" || echo "ЗАПРЕЩЕНО"
От пользователя caddy пробуем создать файл в папке логов.
| Результат | Причина | Решение |
|---|---|---|
ЗАПРЕЩЕНО | Обычные права | chown, права родительских папок |
РАБОТАЕТ, но сервис падает | Причина не в правах на файлы | полный текст ошибки: sudo journalctl -u caddy -n 30 --no-pager |
💡 LogsDirectory= — для своих сервисов. Папку логов можно поручить systemd: при каждом старте он создаёт /var/log/имя с владельцем из User= юнита. Caddy это не нужно — его папку создаёт пакет. Пригодится для своего сервиса (ниже — myapp), который пишет логи в файлы.
sudo systemctl edit myapp
Открывает редактор для «довеска» к юниту (override): свои настройки поверх штатных, без правки файла юнита. Вписать вверху, между строками ### Anything between here and the comment below... и ### Edits below this comment will be discarded:
[Service]
LogsDirectory=myapp
LogsDirectoryMode=0755
LogsDirectory=myapp — при каждом старте systemd создаёт /var/log/myapp с владельцем из User= юнита. LogsDirectoryMode — права на папку.
Затем:
sudo systemctl daemon-reload
sudo systemctl restart myapp
daemon-reload— systemd перечитывает юниты. Послеsystemctl editлишний (он делает это сам, когда закрываешь редактор), оставлен для случая, когда override правили напрямую в файле;restart, а неreload:LogsDirectoryприменяется при старте сервиса.
⚠️ Уже сломанную папку это не чинит. Если папка принадлежит нужному пользователю, systemd файлы внутри не трогает, даже если они от root (man systemd.exec, LogsDirectory=). Поэтому сначала sudo chown -R (выше), потом override.
💡 Override ложится в /etc/systemd/system/myapp.service.d/override.conf и переживает обновление пакета. Правка самого файла юнита из пакета — нет: пакет перезапишет её при обновлении.
💡 Запасной вариант: убрать блоки log из Caddyfile. Логи пойдут в журнал systemd (journalctl -u caddy): одно место, ротация сама, никаких проблем с правами. Но тогда fail2ban из раздела 8 придётся настраивать на журнал, а не на файлы.
7. Диагностика
Набор команд, когда сайт не открывается или ведёт себя странно. Сначала смотрим, жив ли Caddy и что он пишет в лог, потом — что он реально слушает и с каким конфигом работает.
sudo systemctl status caddy --no-pager # жива ли служба, последние строки лога
sudo journalctl -u caddy -f # лог в реальном времени (Ctrl+C — выход)
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile # конфиг без ошибок?
sudo caddy fmt --overwrite /etc/caddy/Caddyfile # выровнять отступы в конфиге
sudo ss -tulpn | grep -E ':80|:443' # кто слушает порты 80/443
curl -s localhost:2019/config/ | jq # действующий конфиг через admin API
💡 Admin API — служебный интерфейс Caddy на localhost:2019, доступен только с самого сервера. Показывает конфиг, с которым Caddy работает прямо сейчас. Полезно, когда правил файл, а изменения не применились.
| Симптом | Причина | Что делать |
|---|---|---|
502 Bad Gateway | Приложение не отвечает | sudo docker ps, логи приложения, порт в reverse_proxy |
504 Gateway Timeout | Приложение приняло запрос, но не ответило | Логи приложения |
ERR_CONNECTION_REFUSED | До Caddy не дошло | sudo ufw status, systemctl status caddy |
| Сертификат не выпускается | DNS / порт 80 / лимит | dig, sudo ufw status, после исправления — sudo systemctl reload caddy |
permission denied на лог | Владелец файлов логов | Раздел 6 |
| Конфиг не применился | Правили не тот файл | curl localhost:2019/config/ — действующий конфиг |
Где что лежит:
| Путь | Что |
|---|---|
/etc/caddy/Caddyfile | Конфиг |
/var/lib/caddy/.local/share/caddy/certificates/ | Сертификаты и ключи |
/var/lib/caddy/.config/caddy/autosave.json | Последний применённый конфиг в JSON |
/var/log/caddy/ | Логи сайтов |
localhost:2019 | Admin API, только с самого сервера |
8. Защита от сканеров
Как только сайт появился, на него придут боты. Большинство безобидны (поисковики), но часть перебирает пути в поисках забытых секретов: /.env с паролями, /.git с исходниками, админки WordPress. Для SSH это уже решает fail2ban из статьи о первичной настройке — теперь то же самое для сайта: находим в логах Caddy тех, кто сыплет ошибками 404, и баним.
⚠️ Сканеры приходят в первые минуты после выпуска сертификата (его видно в логах Certificate Transparency, раздел 2). У меня первый бот пришёл через 1 секунду, первый сканер, перебиравший пути, — через 4,5 минуты, а до этого раздела я добрался через полчаса. Пока тюрьмы нет, сайт и приложение не должны отдавать ничего лишнего: .env, .git, отладочные страницы.
8.1. Сначала посмотреть, кто ходит
Зачем: прежде чем банить, нужно понять, что происходит, — иначе легко забанить своих посетителей или поисковик. Логи Caddy пишутся в формате JSON, по строке на запрос; разбирать их удобно утилитой jq. На Ubuntu Server jq обычно уже стоит (приходит вместе с fwupd). Если jq: command not found — sudo apt install jq.
sudo tail -200 /var/log/caddy/example.log | jq -r '.request.uri' | sort | uniq -c | sort -rn | head -20
Какие пути запрашивают чаще всего. tail -200 — последние 200 запросов, jq -r '.request.uri' — достать из каждой строки путь, sort | uniq -c | sort -rn — посчитать одинаковые и отсортировать по убыванию.
У меня такой вывод — боты уже пришли:
33 /
4 /favicon.ico
3 /v2/_catalog
3 /trace.axd
3 /telescope/requests
3 /server-status
3 /server
3 /s/<id>/_/;/META-INF/maven/com.atlassian.jira/jira-webapp-dist/pom.properties
3 /login.action
3 /info.php
3 /graphql/api
3 /graphql
3 /ecp/Current/exporttool/microsoft.exchange.ediscovery.exporttool.application
3 /debug/default/view?panel=config
3 /console/
3 /config.json
3 /api/graphql
3 /api/gql
3 /api
3 /actuator/env
/ — заходы на главную, часть из них — пока сайт был пустой (раздел 4.2). Остальное — сканеры, сайт к этому моменту работал около получаса. Что они ищут:
| Пути | Что ищут |
|---|---|
/.env, /config.json, /js/env.js, /actuator/env | Конфиги с паролями и ключами API |
/.git/config, /.git/HEAD, /.DS_Store, /.vscode/sftp.json | Забытые служебные файлы: исходники, пароли к серверу |
/telescope/requests, /trace.axd, /info.php, /server-status, /debug/… | Отладочные страницы Laravel, ASP.NET, PHP, Apache, Yii |
/xmlrpc.php, /wp-json/ | WordPress |
/login.action, …/jira-webapp-dist/pom.properties, /ecp/… | Jira, Confluence, Exchange — версии с известными уязвимостями |
/v2/_catalog, /graphql, /api | Открытый Docker Registry и API |
На статике найти нечего — всё это 404. Но если такой файл окажется в папке сайта или приложение отдаст отладочную страницу, найдут его именно так.
sudo tail -200 /var/log/caddy/example.log | jq -r '.request.headers."User-Agent"[0]' | sort | uniq -c | sort -rn | head -15
Кто ходит: User-Agent — строка, которой браузер или бот представляется.
У меня такой вывод:
92 Mozilla/5.0 (l9scan/2.0.<id>; +https://leakix.net)
6 Mozilla/5.0 (compatible; CT-WP-Scanner/1.0; +https://example.com/bot)
4 Mozilla/5.0 (compatible; ForestEngine/1.0; +https://forestengine.net/)
4 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36
4 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.60 Safari/537.36 Edge/12.246
3 curl/8.17.0
3 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
2 nameserver-checker-http-validator/1.0
2 Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1
2 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
1 understory/0.1 (CT research; https://github.com/0x4b) +contact-via-repo
1 Mozilla/5.0 (compatible; SpiderSiteBot/1.0)
1 Mozilla/5.0 (compatible; Databot)
1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36
Часть ботов представляется честно (l9scan — сканер LeakIX, ForestEngine, Databot, CT research), но много и строк обычного Chrome и Edge. Боты неплохо маскируются под пользователей — поэтому ниже банить будем по поведению, а не по User-Agent.
💡 <id> в User-Agent l9scan и в пути Jira — метка сканера LeakIX, а в ней закодирован IP цели: ASCII-коды символов адреса (a.b.c.d) в hex, записанные задом наперёд. При публикации логов это тоже адрес твоего сервера — заменять, как и сам IP.
sudo grep -E '"status":40[034]' /var/log/caddy/example.log | jq -r '"\(.request.remote_ip) \(.request.uri)"' | sort | uniq -c | sort -rn | head -20
Кто получает ошибки 400/403/404 и на каких путях: пары «IP — путь».
У меня такой вывод:
4 <мой-IP> /
2 185.218.86.21 /xmlrpc.php?rsd
2 185.218.86.21 /wp-json/
2 185.218.86.21 /
1 95.153.31.124 /.env.example
1 95.153.31.124 /.env
1 203.0.113.10 /
1 203.0.113.11 /
1 64.89.160.19 /js/env.js
1 64.89.160.19 /config.json
1 64.89.160.19 /config.js
1 64.89.160.19 /api/config
1 64.89.160.19 /README.md
1 64.89.160.19 /CLAUDE.md
1 64.89.160.19 /AGENTS.md
1 64.89.160.19 /.github/copilot-instructions.md
1 64.89.160.19 /.env
1 203.0.113.12 /
1 203.0.113.13 /
1 203.0.113.14 /favicon.ico
<мой-IP> с / — это я сам, пока сайт был пустой. 64.89.160.19 ищет конфиги и заодно файлы инструкций для AI-ассистентов (CLAUDE.md, AGENTS.md, copilot-instructions.md) — в них бывают пути, имена серверов и ключи. 203.0.113.x — заглушки вместо адресов, с которых был только / или favicon.ico: это могли быть живые посетители. Адреса сканеров оставлены как есть.
| Категория | Кто | Что делать |
|---|---|---|
| Полезные | Googlebot, YandexBot, Bingbot, TelegramBot (превью ссылок) | Пускать: без них сайта нет в поиске |
| Нейтральные | Академические сканеры, мониторинги, RSS | Игнорировать |
| Вредные | Перебор /.env, /.git/HEAD, /wp-admin | Банить |
💡 Типичная картина: один IP за секунды даёт 20+ вариантов /.env и /.git/HEAD, а живой посетитель — пару ошибок (обычно favicon.ico). Разделить их порогом легко.
⚠️ Не блокировать по User-Agent — он подделывается одной строкой.
⚠️ Сначала починить свои 404, потом банить. Если favicon.ico отдаёт 404 каждому посетителю, порог будут набирать живые люди:
sudo grep -E '"status":404' /var/log/caddy/example.log | jq -r '.request.uri' | sort | uniq -c | sort -rn | head
Какие пути чаще всего отдают 404 — среди них ищем свои недостающие файлы.
Их (favicon.ico, robots.txt) положить в папку сайта (/var/www/example; если сайт собирается из исходников — в их public/), а не исключать в фильтре.
8.2. robots.txt
Что это: файл в корне сайта, в котором ты просишь ботов не ходить в определённые места. Честные боты (поисковики) его соблюдают. Он нужен, чтобы поисковики правильно индексировали сайт, — и сам по себе убирает лишние 404 на /robots.txt.
Для личного сайта достаточно:
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml
Всем ботам можно всё, карта сайта — по этому адресу.
Закрыть AI-краулеры (по желанию):
User-agent: GPTBot
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Google-Extended
Disallow: /
💡 Google-Extended запрещает использовать сайт для обучения Gemini, не влияя на поиск. Disallow для Googlebot выкинул бы сайт из выдачи.
⚠️ robots.txt — не защита, а просьба. Сканер, который перебирает /.env, его даже не читает. И это публичный список того, что ты хочешь скрыть: Disallow: /admin сообщает всем, что /admin у тебя есть.
⚠️ Если сайт деплоится из CI (у меня так), robots.txt правим в репозитории (public/robots.txt), а не на сервере: деплой с rsync --delete снесёт ручные правки.
8.3. fail2ban на логи Caddy
Как это работает: fail2ban следит за файлом лога, ищет в нём строки по шаблону (фильтр), считает совпадения по каждому IP и, если их больше порога за заданное время, банит IP в firewall (тюрьма). Для SSH всё это было готово из коробки, для Caddy фильтр и тюрьму пишем сами.
Шаг 1. Структура лога — от имён полей зависит шаблон:
sudo tail -1 /var/log/caddy/example.log | jq '{ip: .request.remote_ip, uri: .request.uri, status: .status}'
Последняя строка лога, из неё — только IP, путь и код ответа. Убеждаемся, что поля называются именно так.
Шаг 2. Фильтр — какие строки считать «плохими»:
sudo tee /etc/fail2ban/filter.d/caddy-404.conf > /dev/null << 'EOF'
[Definition]
failregex = ^\{.*"remote_ip":"<HOST>".*"status":40[034].*\}$
ignoreregex = .*"uri":"/(favicon\.ico|apple-touch-icon.*\.png|robots\.txt)"
datepattern = "ts":{EPOCH}
EOF
| Часть | Смысл |
|---|---|
failregex | Строка лога с кодом 400, 403 или 404 — «плохая» |
<HOST> | Место, откуда fail2ban возьмёт IP для бана |
40[034] | 400 (битый запрос), 403 (запрет), 404 (перебор путей) |
ignoreregex | То, что браузеры запрашивают сами, — за это банить нельзя |
datepattern | Где в строке время: Caddy пишет его как unix-время в поле ts |
Шаг 3. Проверить фильтр до применения — прогон по реальному логу, как sshd -t для фильтров:
sudo fail2ban-regex /var/log/caddy/example.log /etc/fail2ban/filter.d/caddy-404.conf
У меня такой вывод (сокращённо):
Failregex: 85 total
| 1) [85] ^\{.*"remote_ip":"<HOST>".*"status":40[034].*\}$
Ignoreregex: 8 total
| 1) [8] .*"uri":"/(favicon\.ico|apple-touch-icon.*\.png|robots\.txt)"
Date template hits:
| [179] "ts":{EPOCH}
Lines: 179 lines, 8 ignored, 85 matched, 86 missed
- ✅
Failregex: N total→ столько строк фильтр счёл «плохими» - ✅
Ignoreregex: N total→ исключения работают - ✅
Date template hitsравно числуLines→ время читается в каждой строке missed— норма: это ответы200,308и прочие, не подходящие под фильтр- ❌
0 total, хотя 404 в логе есть → шаблон не совпадает со структурой лога
⚠️ Тюрьму включать, только когда сайт уже отдаёт страницы. Пока папка сайта пустая, 404 получают все, включая живых посетителей и тебя самого, — и тюрьма забанит своих. У меня из 85 «плохих» строк заметная часть — заходы на главную до того, как появился index.html.
Шаг 4. Тюрьма — где искать, по какому фильтру, с каким порогом и на сколько банить. Дописываем в jail.local из статьи о первичной настройке:
sudo tee -a /etc/fail2ban/jail.local > /dev/null << 'EOF'
[caddy-404]
enabled = true
backend = polling
filter = caddy-404
logpath = /var/log/caddy/*.log
maxretry = 10
findtime = 5m
bantime = 24h
port = http,https
protocol = tcp,udp
EOF
⚠️ tee -a — дописать в конец. Без -a файл перезапишется, и настройки sshd пропадут.
| Параметр | Почему так |
|---|---|
backend = polling | Читать файл, а не журнал systemd — без этой строки тюрьма может ничего не ловить (8.4, пункт 1) |
maxretry = 10 | Сканер набирает 20+, посетитель 1–2 — запас в обе стороны |
findtime = 5m | Сканер проходит словарь за секунды |
bantime = 24h | Для перебора мягкость не нужна. Повторные баны растут по правилам [DEFAULT] (bantime.increment) до bantime.maxtime |
logpath = /var/log/caddy/*.log | Маска подхватит логи всех сайтов |
port = http,https | Только веб. SSH бан не затронет — себя не отрежешь |
protocol = tcp,udp | По умолчанию бан ставится только на TCP. HTTP/3 идёт по UDP 443 (открыт в 2.2) — без этой строки забаненный продолжает ходить на сайт по HTTP/3 |
Шаг 5. Применить и проверить:
sudo fail2ban-client -t
Проверка конфигов fail2ban, как sshd -t: синтаксис и параметры, в том числе дубли опций.
- ✅
OK: configuration test is successful - ❌
ERRORс текстом ошибки → перезапускать нельзя, сначала починить;option … already exists→ 8.4, пункт 2
sudo systemctl restart fail2ban && sleep 5 && systemctl is-active fail2ban
Перезапуск и проверка, что поднялся. sleep 5 — fail2ban на Python стартует несколько секунд.
sudo fail2ban-client status caddy-404
- ✅
active, в статусе тюрьмы естьFile list: /var/log/caddy/… - ❌
Journal matchesвместоFile list→ 8.4, пункт 1 - ❌
is-activeпишетfailed→ 8.4, пункт 2
Бан закрывает и TCP, и UDP — проверяем, какое правило fail2ban ставит в firewall. Ждать настоящего бана не нужно: вручную баним адрес из диапазона для документации (203.0.113.x, в интернете его нет).
sudo fail2ban-client set caddy-404 banip 203.0.113.99
sudo nft list ruleset | grep -B2 -A8 caddy-404
sudo fail2ban-client set caddy-404 unbanip 203.0.113.99
-
set caddy-404 banip— забанить адрес в тюрьме вручную, как будто он превысил порог; -
nft list ruleset— все правила firewall; fail2ban пишет свои в таблицуf2b-table,grepоставляет нашу тюрьму,-B2 -A8— строки до и после совпадения, чтобы видеть правило целиком; -
unbanip— снять тестовый бан. -
✅ В
f2b-chainдве строки:tcp dport { 80, 443 } … rejectиudp dport { 80, 443 } … reject -
❌ Только
tcp dport→ нет строкиprotocol = tcp,udpв тюрьме или fail2ban не перезапущен
Шаг 6. Проверка боем — забанить самого себя. Чтобы не ждать сутки, временно ставим бан на минуту (меняется только в работающем демоне, файл не трогается):
sudo fail2ban-client set caddy-404 bantime 60
set … bantime 60 — время бана для тюрьмы caddy-404 в секундах, только до перезапуска fail2ban.
С локальной машины — 12 запросов к несуществующим путям (порог — 10):
foreach ($i in 1..12) { curl.exe -s -o NUL -w "$i " "https://example.com/.env.test$i" }
foreach ($i in 1..12) — 12 повторов; -s — без прогресса, -o NUL — тело ответа в никуда (NUL — это /dev/null в Windows), -w "$i " — печатать номер запроса, чтобы видеть, что все 12 ушли.
На сервере:
sudo fail2ban-client status caddy-404
- ✅ Свой IP в
Banned IP list,Total failed— 10 или больше (запросы после бана до сервера уже не доходят) - ✅
curl.exe -m 5 -I https://example.comс локальной машины не проходит (таймаут или отказ соединения);-m 5— ждать не дольше 5 секунд,-I— только заголовки - ✅ SSH продолжает работать
- ⏳ Через минуту бан снимается сам
- ❌ Бана нет,
Total failed: 0→ 8.4, пункт 1
⚠️ Бан появляется не мгновенно: polling проверяет файл раз в секунду, плюс время на обработку и бан. У меня — от доли секунды (11 запросов прошли, 12-й уже нет) до нескольких секунд. Пусто сразу после curl — подожди и проверь ещё раз.
💡 Статус смотреть, пока бан действует. Счётчики Total failed и Total banned живут в памяти fail2ban и обнуляются при перезапуске — после возврата bantime (ниже) там будут нули. История банов остаётся в логе:
sudo grep 'caddy-404' /var/log/fail2ban.log | tail -20
- ✅ Строки
Found <IP>, затемBan <IP>и через минутуUnban <IP>
У меня такой вывод (сокращённо) — тюрьма поймала настоящий сканер:
14:42:40,826 fail2ban.filter [7378]: INFO [caddy-404] Found 20.211.97.120 - 2026-10-15 14:42:40
... (ещё 8 строк Found за 3 секунды)
14:42:44,052 fail2ban.filter [7378]: INFO [caddy-404] Found 20.211.97.120 - 2026-10-15 14:42:43
14:42:44,220 fail2ban.actions [7378]: NOTICE [caddy-404] Ban 20.211.97.120
14:43:43,456 fail2ban.actions [7378]: NOTICE [caddy-404] Unban 20.211.97.120
10 «плохих» запросов за 3 секунды, бан — через 0,17 с после 10-го. Unban через минуту — потому что ещё стоял тестовый bantime 60 (см. ниже).
⚠️ Вернуть bantime сразу после проверки. Пока стоит 60, настоящие сканеры тоже отсиживают минуту вместо суток. У меня тестовое значение провисело 10 минут, и сканер, пришедший через 4 минуты после теста, fail2ban отпустил через минуту.
Вернуть боевое значение — перезапуск перечитает jail.local:
sudo systemctl restart fail2ban && sleep 5 && sudo fail2ban-client get caddy-404 bantime
- ✅
86400(24 часа в секундах)
Управление банами:
sudo fail2ban-client status caddy-404
sudo fail2ban-client set caddy-404 unbanip <IP> # разбанить
sudo fail2ban-client set caddy-404 banip <IP> # забанить вручную
8.4. Если fail2ban не работает
Не шаг настройки — сюда, если в шаге 5 или 6 раздела 8.3 что-то не так. Два случая, на которые я наступал: симптом → проверка → причина → починка.
1. Тюрьма есть, но ничего не ловит.
Симптом — в sudo fail2ban-client status caddy-404:
| |- Total failed: 0 ← хотя в логе полно 404
| `- Journal matches: ← вместо File list
Проверка — какой файл читает тюрьма:
sudo fail2ban-client get caddy-404 logpath
- ❌
No file is currently monitored→ тюрьма читает журнал systemd,logpathигнорируется
То же видно в логе fail2ban — при каждом старте он пишет, откуда читает тюрьма:
sudo grep "Jail 'caddy-404' uses" /var/log/fail2ban.log | tail -1
- ✅
Jail 'caddy-404' uses poller {}— читает файл (backend = polling) - ❌
uses systemd→ тюрьма читает журнал
Причина: у fail2ban есть backend — откуда он берёт события: systemd — журнал systemd (так настроен sshd), polling — перечитывать файл раз в секунду. Caddy пишет в файлы, а тюрьма без своей строки backend берёт его из настроек по умолчанию — и может достаться systemd. У меня на Ubuntu 24.04 systemd стоял в [DEFAULT] для всех тюрем; на 26.04 в [DEFAULT] стоит auto, а systemd прописан только для sshd.
Починка: в секции [caddy-404] в /etc/fail2ban/jail.local должна быть строка backend = polling (в шаге 4 она уже есть — значит, при копировании потерялась). Открыть файл и дописать её под [caddy-404]:
sudo nano /etc/fail2ban/jail.local
sudo systemctl restart fail2ban && sleep 5 && sudo fail2ban-client status caddy-404
- ✅ В статусе
File list: /var/log/caddy/…
2. fail2ban не стартует: option … already exists.
Симптом — sudo fail2ban-client -t выдаёт ошибку вместо OK. Если перезапустили без проверки — systemctl is-active fail2ban пишет failed, а в sudo journalctl -u fail2ban -n 20 --no-pager:
ERROR Failed during configuration: While reading from '/etc/fail2ban/jail.local'
[line 14]: option 'backend' in section 'caddy-404' already exists
Причина: одна и та же опция дважды в одной секции. Обычно — повторный запуск команды, которая дописывает строку в файл.
Проверка — найти дубли с номерами строк:
grep -n 'backend' /etc/fail2ban/jail.local
Вместо backend — имя опции из ошибки. Две строки в одной секции — это дубль.
Починка: удалить строку по номеру из ошибки и перезапустить:
sudo sed -i '14d' /etc/fail2ban/jail.local
sudo systemctl restart fail2ban && sleep 5 && systemctl is-active fail2ban
sed -i '14d' — удалить 14-ю строку прямо в файле.
💡 У меня дубль появился от команды sed -i '/^\[caddy-404\]/a backend = polling' («вставить строку после [caddy-404]»), запущенной дважды стрелкой вверх. Такие команды не идемпотентны — каждый запуск добавляет ещё одну строку. Замена s/старое/новое/ идемпотентна: второй раз заменять уже нечего.
Итог: чеклист
- DNS всех имён указывает на сервер, записи
AиAAAA(dig +short …) - Порты 80/tcp, 443/tcp, 443/udp открыты (
sudo ufw status) - Caddy работает (
systemctl is-active caddy) - В папке сайта есть
index.html(ls -la /var/www/example) - Конфиг валиден (
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile) - Сертификаты выпущены (
certificate obtained successfullyвsudo journalctl -u caddy) - Снаружи: HTTPS —
200, HTTP —308на HTTPS (curl.exe -I …) - Логи пишутся, файлы принадлежат
caddy(ls -l /var/log/caddy/) - Приложение слушает только
127.0.0.1(sudo ss -tulpn | grep 3000) - fail2ban: тюрьма
caddy-404читает файлы (sudo fail2ban-client status caddy-404→File list) - fail2ban: бан закрывает и UDP (
banip 203.0.113.99→ вsudo nft list rulesetестьudp dport { 80, 443 }) - fail2ban: после проверки боем
bantimeвернулся на сутки (sudo fail2ban-client get caddy-404 bantime→86400)