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 принимает решение по запросу
input
Запрос
Новый HTTP request
matcher
Зона?
Подходит ли запрос под rate_limit
ok
Пропустить
Запрос идёт дальше
identity
Ключ
Например, client_ip
state
Счётчик
Ring buffer этого ключа в зоне
window
Проверка
events за window превышен?
limit
429
Ответ + 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, отделить ботов от нормальных пользователей и настроить лимиты так, чтобы они защищали проект, а не ломали продажи и поддержку.