Rate limit — это ограничение «сколько запросов с одного источника я готов принять за единицу времени». Нужен он, чтобы пережить брутфорс логина, абуз дорогих ручек API и просто чужую кривую интеграцию, которая долбит вас в цикле. В Caddy для этого есть отличный модуль, но у него хватает нюансов, на которых легко собрать конфиг, который выглядит рабочим, а на деле либо не ограничивает никого, либо блокирует всех разом.
Что это даёт владельцу сервиса#
Rate limit не делает сервис неуязвимым, но убирает класс дешёвых проблем: бесконечные попытки логина, случайные циклы интеграций, слишком частые запросы к API, нагрузку от простых ботов.
Для бизнеса это выглядит не как «настроили лимитер», а как меньше ложных падений, меньше ручного разбора логов и более предсказуемая работа публичных точек входа.
Эта статья — универсальный шаблон под любой проект плюс разбор модели модуля по его исходникам, чтобы вы понимали, что именно происходит, а не копировали наугад.
Это сторонний модуль#
rate_limit не входит в стоковый Caddy. Это модуль mholt/caddy-ratelimit, и Caddy нужно собрать вместе с ним:
xcaddy build --with github.com/mholt/caddy-ratelimit
Либо использовать готовый образ, в котором модуль уже встроен. Проверить, что модуль на месте, можно так:
caddy list-modules | grep rate_limit
# http.handlers.rate_limit
Если строки нет — никакие директивы rate_limit работать не будут, Caddy просто не запустит конфиг.
Мой образ. Чтобы каждый раз не собирать Caddy руками, я держу готовый образ
webzaytsev/caddy-dns-pro— в нём уже встроеныrate_limit,dynamic_dnsи DNS-провайдеры (Cloudflare, Selectel и др.) для DNS-01 ACME и динамического DNS. Можно взять его как есть:services: caddy: image: webzaytsev/caddy-dns-pro:latest restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - ./config:/config - ./data:/data cap_add: - NET_ADMIN
Как устроен модуль#
Три понятия, которые важно различать:
- Зона — это и есть один лимит: «N событий за окно». У зоны есть имя.
- Ключ — это то, по чему мы группируем счётчики внутри зоны. Каждое уникальное значение ключа получает свой счётчик (ring buffer). Ключ
{client_ip}означает «считаем отдельно по каждому IP». - Окно — скользящее (sliding window): модуль смотрит назад на
windowи проверяет, не было ли ужеeventsсобытий. Если было — отдаёт429 Too Many Requestsи ставит заголовокRetry-After.
Самое важное: имена зон глобальны#
Имя зоны уникально на весь процесс Caddy, а не на блок rate_limit и не на сайт — в исходниках модуля это указано прямо:
// RateLimits contains the definitions of the rate limit zones, keyed by name.
// The name MUST be globally unique across all other instances of this handler.
Состояние зон хранится в глобальном пуле: при загрузке конфига модуль регистрирует зону по имени (LoadOrStore). Если зона с таким именем уже есть — переиспользуется тот же набор счётчиков, а настройки (events/window) берутся из блока, который Caddy обработал последним.
Каждая зона должна иметь уникальное имя. Если использовать одно имя в двух местах с разными лимитами, они будут делить один счётчик, а итоговый лимит станет непредсказуемым — применится тот блок, который Caddy обработал последним.
client_ip, remote_ip и trusted_proxies#
Второй важный момент — по какому адресу считать клиента. Если вы стоите за Cloudflare (или любым прокси), все запросы физически приходят с IP-адресов прокси, а реальный IP клиента лежит в заголовке X-Forwarded-For.
В Caddy за это отвечают:
remote_ip— IP непосредственного соединения. За Cloudflare это адрес edge-ноды CF, а не клиента.{client_ip}/ матчерclient_ip— реальный IP клиента, но только если настроенtrusted_proxies. Без негоclient_ipведёт себя ровно какremote_ip(это поведение прямо описано в документации Caddy).
Отсюда правило: за прокси обязательно настраиваем trusted_proxies, иначе и rate limit, и whitelist будут работать по адресам CF, а не по клиентам.
{
servers {
# доверяем только адресам прокси; тогда client_ip берётся из X-Forwarded-For
trusted_proxies static 203.0.113.0/24 198.51.100.0/24
trusted_proxies_strict
}
}
trusted_proxies_strict парсит X-Forwarded-For справа налево и рекомендуется за Cloudflare/HAProxy/ALB: левый IP в заголовке клиент может подделать, а правый дописывает доверенный прокси. Для Cloudflare удобнее не хардкодить диапазоны, а использовать IP-source плагин (trusted_proxies cloudflare {…}), который сам подтягивает актуальные подсети CF — но это снова требует кастомного билда.
Если у вас «серое облако» (DNS-only, без проксирования CF), то
remote_ipуже равен реальному клиенту, иtrusted_proxiesне нужен. Но{client_ip}с настроеннымtrusted_proxies— универсально правильный выбор в обоих случаях.
Переиспользуемые сниппеты#
Caddyfile позволяет выносить повторяющиеся куски в именованные сниппеты (…) и подключать их через import. Вот база, которая ложится на любой проект.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
X-Frame-Options "SAMEORIGIN"
-Server
}
}
(common-encode) {
encode zstd gzip
}
# Универсальный лимит.
# args[0] — ИМЯ ЗОНЫ (глобально уникальное!), args[1] — событий в минуту.
(rl) {
rate_limit {
log_key
zone {args[0]} {
key {client_ip}
events {args[1]}
window 1m
}
}
}
# Защита логина: быстрый бурст 5/мин + медленный брутфорс 15/10мин.
# args[0] — префикс имени зоны (тоже уникальный на сайт).
(rl_login) {
@login path /api/login
rate_limit @login {
log_key
zone {args[0]}_login_burst {
key {client_ip}
events 5
window 1m
}
zone {args[0]}_login_slow {
key {client_ip}
events 15
window 10m
}
}
}
# Whitelist по реальному IP клиента (требует trusted_proxies, иначе = remote_ip).
(ip_whitelist) {
@blocked not client_ip 203.0.113.10 198.51.100.0/24
handle @blocked {
respond 403
}
}
Обратите внимание на (rl_login): две зоны в одном блоке — это легальный и удобный паттерн. Бурст ловит автоматический перебор паролей, а медленное окно — «тихий» брутфорс по паре попыток в минуту, который проскользнул бы мимо лимита 5/мин.
Порядок обработки: rate_limit до auth#
Если в конфиге есть forward_auth или basicauth, нужно явно указать позицию rate_limit в цепочке обработки. Без этого Caddy может поставить его после auth-директив — auth-сервис получит нагрузку раньше, чем лимит срежет лишние запросы.
Добавить в глобальный блок:
{
order rate_limit before basicauth
}
С ним цепочка: rate_limit → forward_auth → бэкенд. Без него порядок не гарантирован.
Примеры по сайтам#
Дальше — как это подключать. У каждой зоны здесь своё уникальное имя (api_main, site_static, admin_ui, auth_ui, auth_login_*).
api.example.com {
import common-encode
import security_headers
import rl api_main 600
reverse_proxy app-backend:3001
}
example.com {
import common-encode
import security_headers
import rl site_static 1200
root * /front
try_files {path} {path}/index.html /index.html
file_server
}
admin.example.com {
import security_headers
import rl admin_ui 300
import ip_whitelist
reverse_proxy admin-ui:3003
}
auth.example.com {
import security_headers
import rl auth_ui 120
import rl_login auth
reverse_proxy auth:8080 {
header_up X-Real-IP {client_ip}
}
}
Лимиты подбираются под характер трафика. Статика и SPA генерируют десятки запросов на одну загрузку страницы (чанки, картинки, шрифты) — туда ставим щедрый лимит (1200/мин), иначе обычный пользователь сам себя забанит. API — умереннее (600/мин). Админка и логин — строго.
Частые ошибки#
Три ошибки, которые выглядят правдоподобно, но не работают как задумано.
1. Одно имя зоны на два разных лимита#
# плохо: имя зоны per_ip используется дважды с РАЗНЫМИ лимитами
(public-rate-limit) {
rate_limit {
zone per_ip { key {client_ip} events 3000 window 1m }
}
}
(protected-rate-limit) {
rate_limit {
zone per_ip { key {client_ip} events 1200 window 1m }
}
}
Имена зон глобальны, поэтому обе «зоны» — это одна и та же зона. Публичные и защищённые сайты делят общий счётчик, а применённый лимит (3000 или 1200) зависит от того, какой блок Caddy обработал последним. Чинится тривиально — уникальные имена:
# хорошо
(public-rate-limit) {
rate_limit {
zone public_per_ip { key {client_ip} events 3000 window 1m }
}
}
(protected-rate-limit) {
rate_limit {
zone protected_per_ip { key {client_ip} events 1200 window 1m }
}
}
2. Ключ по client_ip без trusted_proxies за Cloudflare#
Если вы за оранжевым облаком CF, но trusted_proxies не настроен, {client_ip} схлопывается в IP edge-ноды Cloudflare. Все ваши посетители выглядят как несколько адресов CF → попадают в один-два счётчика → лимит выкашивает всех разом, как только суммарный трафик превышает порог. Лечится настройкой trusted_proxies (см. выше).
3. Whitelist по remote_ip за прокси#
# плохо: за Cloudflare remote_ip — это адрес CF, а не клиента
@blocked not remote_ip 203.0.113.10
remote_ip не смотрит в X-Forwarded-For. За прокси он равен адресу CF, поэтому «не из вайтлиста» окажутся вообще все, и сайт отдаст 403 каждому. За прокси whitelist всегда по client_ip (и снова — с настроенным trusted_proxies).
Как проверить, что лимит реально работает#
Не верьте конфигу на слово — проверьте поведением.
# 1) долбим эндпоинт и ждём переключения 200 -> 429
for i in $(seq 1 50); do
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/
done
# 2) после превышения смотрим, что пришёл Retry-After
curl -sI https://example.com/ | grep -i retry-after
# 3) логи Caddy: при включённом log_key видно имя зоны и ключ
docker compose logs caddy | grep "rate limit exceeded"
В логах при срабатывании будет запись со zone (имя зоны, которое уперлось в лимит) и key (тот самый IP). Это лучший способ убедиться, что лимит считает по правильному адресу, а не по edge CF: если в key стабильно светятся адреса Cloudflare — значит trusted_proxies не подхватился.
Чек-лист#
- Модуль
rate_limitреально встроен в бинарник (caddy list-modules). - У каждой зоны глобально уникальное имя.
- За прокси настроен
trusted_proxies(+trusted_proxies_strictза Cloudflare). - Ключ rate limit и whitelist — по
{client_ip}/client_ip, а неremote_ip. - Если используется
forward_auth/basicauth— в глобальном блоке стоитorder rate_limit before basicauth. - Логин защищён отдельной зоной (бурст + медленное окно).
- Лимиты на статику/SPA достаточно щедрые, чтобы не банить обычного пользователя.
- Поведение проверено: переход на
429иRetry-Afterглазами вcurl, ключ — в логах.
Если у проекта есть логин, API, вебхуки или публичные формы, rate limit лучше настраивать до первого инцидента. Напишите мне в Telegram или на почту — помогу разобрать текущую конфигурацию Caddy, отделить ботов от нормальных пользователей и настроить лимиты так, чтобы они защищали проект, а не ломали продажи и поддержку.



