одна дверь в дом: caddy, docker, и обратный прокси

1 июля 2026 г. devlogdocker

Шесть контейнеров, одна дверь

Весь стек это шесть сервисов в docker compose: postgres, minio, auth, blog, frontend и caddy стоящий перед всеми. Только один из них, caddy, действительно открывает порт внешнему миру. Всё остальное разговаривает со всем остальным через compose сеть, от названия контейнера к названию контейнера, полностью невидимо снаружи.

Это вся идея обратного прокси, когда убираешь словарный запас: одна дверь в дом, и какая бы комната тебе ни была нужна, дверь это понимает для тебя на основе пути который ты запросил. Браузер знает только один адрес. Он не имеет идеи что auth и blog это даже отдельные процессы бегущие отдельно.

handle /media/* {
    request_body {
        max_size 2MB
    }
    reverse_proxy minio:9000
}

handle {
    request_body {
        max_size 2MB
    }
    reverse_proxy frontend:3000
}

Всё что не явно совпадает выше падает в последний чистый блок handle внизу файла, который идет на frontend. Frontend потом делает свой собственный внутренний проксинг к auth и blog для реальных API вызовов, поэтому с точки зрения Caddy реально только два пункта назначения имеют значение напрямую: minio для сырых медиа файлов, и frontend для абсолютно всего остального.

Автоматический TLS, вещь которую я на самом деле еще не тестировал

Вся репутация Caddy это автоматический HTTPS, он получит настоящий сертификат от Let's Encrypt просто из называния реального домена в конфиге. Локально ничего этого не применяется, {$DOMAIN} по умолчанию localhost и Caddy просто сервирует простой HTTP, вообще не нужен танец сертификата. Момент когда это реально идет на реальный домен, та же самая строка конфига должна просто начать запрашивать и обновлять сертификаты сама по себе, нет отдельной certbot cron работы, нет ручного обновления когда-либо. Я не тестировал эту часть для реального потому что этот блог не живет на реальном домене, что честно сказать самая часть здесь которую я меньше всего уверен будет гладко в первый раз. Всё всегда выглядит просто в документах пока ты не тот кто реально это запускает.

Один переход, доверенный, ничего дальше чем это

Сидение позади прокси ломает две вещи о которых ты не думаешь пока они не сломаются: собственная CSRF проверка frontend, которая сравнивает источник запроса с чем он думает его собственный адрес это, и rate limiting, которому нужен реальный IP посетителя, не Caddy.

# Позади Caddy источник браузера это публичный DOMAIN, не :3000.
# Получить источник из Caddy форвордед хедеров так что adapter-node
# CSRF/origin проверка на POST форм проходит для http://localhost и
# https://<domain>.
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: x-forwarded-for
XFF_DEPTH: "1"

Та последняя строка, XFF_DEPTH: "1", потребовала больше всего времени чтобы действительно понять. Без этого, getClientAddress() просто возвращает какой-то контейнер открыл сокет, который всегда Caddy, потому что Caddy единственное что когда-либо подключается к frontend контейнеру напрямую. Говорить ему доверять ровно один переход означает он читает адрес клиента из хедера который Caddy сам добавил, не из чего-то что клиент может подделать дальше вверх цепи добавляя поддельные записи перед своей собственной. Ошибаться в этом числе в любом направлении, доверять нулю переходов и каждый посетитель выглядит как Caddy, доверять слишком много и вредный клиент может просто соврать о том кто они.

Healthchecks: делание «вверх» чем-то значимым

Каждый сервис в compose имеет healthcheck, и я не воспринимал эти серьезно вначале, просто скопировал один из примера и двигался дальше:

# что я скопировал из примера, не думал об этом дальше
frontend:
  healthcheck:
    test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

Потом я действительно нужен был depends_on: condition: service_healthy чтобы работать правильно, потому что blog не должен даже начинать принимать трафик пока postgres действительно готов отвечать на запросы, не просто «процесс контейнера запустился».

frontend:
  healthcheck:
    # Использовать 127.0.0.1, не localhost: в Alpine localhost разрешает на IPv6 ::1
    # в то время как Node сервер слушает только на IPv4, так что localhost проверка
    # не подключается и контейнер неправильно отмечается нездоровым.
    test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

Этот 127.0.0.1 вместо localhost стоил мне честно запутанного получаса. На основанных на Alpine образах где всё здесь работает, localhost разрешает на IPv6 loopback сначала, но Node сервер слушает только на IPv4 сокета, поэтому healthcheck подключился ни к чему, timeout, и отметил совершенно рабочий контейнер нездоровым. Compose потом отказался поднимать что-либо зависящее от этого, поэтому весь стек просто сидел там, всё действительно хорошо внутри, docker убежден иное. Обмен на буквальный IP исправил это напрямую, и сейчас я по умолчанию используюю 127.0.0.1 в каждом healthcheck из привычки вместо доверять что localhost означает что я думаю это означает внутри контейнера.

Собственный healthcheck Caddy попадает на его внутренний админ API вместо публичного сайта по той же самой причине, поэтому он продолжает работать раз DOMAIN становится реальным именем хоста и простой запрос http://localhost будет перестать совпадать с чем бы то ни было.

То же самое рассуждение работает через postgres, minio, auth и blog тоже, каждый тестирует что-то что действительно доказывает готовность (pg_isready, MinIO его собственный health endpoint, каждого Go сервиса /healthz) вместо того чтобы просто проверить что процесс не полностью упал. Упавший процесс и процесс который вверх но всё еще работает собственные startup миграции выглядят одинаково снаружи если всё что ты проверяешь это «он принял TCP подключение», и я только пекся о различии раз compose действительно участвовал в race условии auth миграций против blog пытающегося разговаривать с базой данных которая ещё не была для этого готова.

Gotcha сопоставления путей

Одна вещь про Caddy которая не очевидна из краткого чтения docs: блоки handle не совпадают в порядке в котором ты их писал, они совпадают по тому как конкретен путь pattern. Есть блок для /auth/permissions который возвращает чистый 404 (тот endpoint сервирует внутреннюю матрицу role-to-capability которой не место быть достижимой из публичного GET), сидящий хорошо выше перехватывающего handle {} в самом низу файла. Не имеет значения что он не последний, более конкретный путь всегда побеждает невзирая где он сидит. Потребовалось действительно читать документацию Caddy вместо гадания доверять этому, потому что это точно вид предположения («наверно первое совпадение побеждает, как всё остальное что я использовал») что имело бы молчаливо сломать что-то в итоге, вероятно в худший момент.

Перезагрузка конфига Caddy без отпускания подключения

0 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?