Перейти к основному содержанию

Caddy в контейнере: как разделить публичные и приватные сервисы

Введение

Типичная self-hosted инфраструктура включает десятки сервисов: git-хостинг, мониторинг, RSS-ридер, менеджер паролей, заметки. Часть из них должна быть доступна из интернета, часть — только из приватной сети (VPN/mesh). Caddy1 — удобный reverse proxy с автоматическим TLS — отлично справляется с маршрутизацией и терминацией HTTPS для всех этих сервисов.

Проблема возникает, когда Caddy работает в контейнере (Docker, Podman) и нужно разделить доступ: публичные сервисы для всех, приватные — только для клиентов из VPN-сети. Казалось бы, достаточно проверить IP-адрес клиента через matcher remote_ip. Но контейнерная сеть вносит свои коррективы.

В этой статье я разберу проблему потери client IP в контейнеризированном Caddy, рассмотрю девять вариантов решения, подробно сравню два лучших и покажу реализацию выбранного подхода.

Проблема: потеря client IP в контейнере

Caddy работает в rootless Podman контейнере на custom bridge network. Все входящие TCP-соединения проходят через pasta port forwarder, который выполняет SNAT (Source NAT). В результате Caddy видит IP шлюза внутренней сети, а не реальный IP клиента2:

Клиент (203.0.113.50) -> хост:443 -> pasta port forwarder -> bridge network
-> SNAT -> Caddy (видит remote_ip = 10.89.0.1)

Это делает невозможным:

  • Фильтрацию по remote_ip для ограничения доступа к приватным сервисам
  • Корректное логирование IP клиентов
  • Использование trusted_proxies / X-Forwarded-For — IP потерян на TCP-уровне, до HTTP3

Custom bridge network необходима для inter-container DNS — Caddy обращается к backend-сервисам по имени контейнера (forgejo:3000, grafana:3000). Без неё пришлось бы публиковать порты каждого backend-а и обращаться через host.containers.internal, что ненадёжно и неудобно.

Проблема специфична для rootless-контейнеров с custom network. Вот как ведут себя разные сетевые режимы:

Метод сетиSource IPInter-container DNS
Socket activation (systemd)сохраняетсяда (custom network)
Pasta (без custom network)сохраняетсянет
Pasta + custom networkтеряетсяда
Host networkсохраняетсянет

Источник: podman-networking-docs 4.

Почему фильтрация только по IP недостаточна

Даже если бы remote_ip работал в контейнере, фильтрация только на уровне IP-адреса — не лучший подход к безопасности:

DNS не является средством контроля доступа. Вариант «приватные домены резолвятся только в VPN» (Split DNS) не защищает от прямого обращения по IP или от утечки DNS-записей. IP сервера известен, порты открыты — злоумышленник может отправить запрос с правильным заголовком Host напрямую.

Caddy ответит на любой запрос с валидным SNI. Если в Caddyfile есть site block для grafana.example.org, Caddy обработает запрос на этот домен независимо от того, откуда пришёл клиент (при условии, что remote_ip matcher не работает из-за SNAT). Ошибка в конфигурации remote_ip — и приватный сервис открыт в интернет.

Нужна физическая или сетевая изоляция. Надёжный подход — вынести приватные сервисы на отдельный сетевой интерфейс, порты которого не опубликованы в интернет. Тогда даже при ошибке конфигурации приватный трафик физически не может прийти извне.

Варианты решения

В ходе исследования рассмотрено девять вариантов. Вот краткая сводка:

ВариантSource IPИзоляцияInter-containerПодходит
1. Socket activationдалогическаядада
2. Caddy на хостеданет (Caddy)через портычастично
3. Два Caddyпубл: нет, TS: дафизическаядада
4. caddy-tailscale плагинтолько TSдадачастично
5. Tailscale sidecar (Caddy)только TSданетчастично
6. Pasta без custom netда (внешние)данетнет
7. Host networkограниченонетнетнет
8. Tailscale sidecar (backend)через ACLданетнет
9. Split DNSне решаетне меняетне меняетдополнение

Варианты 2, 6, 7 ломают inter-container DNS. Вариант 4 (caddy-tailscale) экспериментальный и не решает проблему для публичного трафика. Вариант 8 требует sidecar на каждый сервис — +10-20 MB RAM и сложное управление при 20+ сервисах. Вариант 9 (Split DNS) — дополнение, а не решение.

Два варианта прошли в финал: socket activation и два Caddy с Tailscale sidecar.

Два финалиста

Почему именно эти два

Socket activation — рекомендация, основанная на минимальности изменений. Один Caddy, одна конфигурация, source IP сохраняется для всего трафика. Проверенный production-паттерн с документированными примерами56.

Два Caddy — альтернатива, выбранная из-за физической изоляции. Приватные порты не опубликованы — ошибка в Caddyfile не приведёт к утечке. Defense in depth: даже при компрометации публичного Caddy приватные сервисы недоступны.

Socket activation

Как это работает

systemd создаёт сокеты на портах 80/443 на хосте через .socket unit. При входящем соединении file descriptors передаются в контейнер. Caddy получает TCP-соединения с реальным client IP, минуя pasta port forwarder. При этом Caddy остаётся на custom network для связи с backend-ами56:

Без socket activation:
  Клиент (203.0.113.50) -> хост:443 -> pasta -> SNAT -> Caddy (видит 10.89.0.1)

С socket activation:
  Клиент (203.0.113.50) -> хост:443 -> systemd socket -> fd/4 -> Caddy (видит 203.0.113.50)
  Caddy -> custom network -> backend-контейнеры (по имени)

Конфигурация

Socket unit (caddy.socket):

[Socket]
BindIPv6Only=both
ListenStream=[::]:80    # fd/3
ListenStream=[::]:443   # fd/4
ListenDatagram=[::]:443 # fd/5 (QUIC/HTTP3)

[Install]
WantedBy=sockets.target

Caddyfile с fd-binding:

{
    auto_https disable_redirects
    default_bind fd/4 { protocols h1 h2 }
    default_bind fdgram/5 { protocols h3 }
    admin unix//run/admin.sock
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

http:// {
    bind fd/3
    redir https://{host}{uri} permanent
}

(private) {
    @not_vpn not remote_ip 100.64.0.0/10
    respond @not_vpn "Forbidden" 403
}

git.example.org {
    reverse_proxy forgejo:3000
}

grafana.example.org {
    import private
    reverse_proxy grafana:3000
}

Плюсы

  • Source IP сохраняется для всего трафика — и публичного, и VPN
  • Один Caddy, один Caddyfile, один набор сертификатов
  • Нативная производительность сети
  • Backend-сервисы без изменений
  • Проверенный паттерн с Quadlet-примерами56

Минусы

  • auto_https не работает — нужен явный HTTP->HTTPS redirect
  • systemctl reload не работает — reload только через podman exec7
  • Нумерация fd хрупкая — при нескольких .socket файлах порядок не гарантирован8
  • Изоляция логическая — ошибка в matcher remote_ip откроет приватный сервис

Два Caddy с Tailscale sidecar

Как это работает

Публичный Caddy обслуживает интернет-сервисы через PublishPort. Приватный Caddy делит network namespace с Tailscale sidecar-контейнером — видит интерфейс tailscale0 и получает реальные VPN IP клиентов. Приватные порты не опубликованы — доступ только через VPN910:

Интернет -> PublishPort -> Caddy-public (IP потерян, но не нужен)
    -> git, reader, vault, id

VPN (100.64.0.X) -> WireGuard -> tailscale0 -> Caddy-private (видит реальный VPN IP)
    -> grafana, prometheus, notes, search

Ключевая деталь: shared namespace + bridge network

Tailscale sidecar подключён к custom bridge network. Caddy-private разделяет его network namespace (Network=tailscale-sidecar.container). Это означает, что Caddy-private видит одновременно:

  • bridge-интерфейс на custom network (inter-container DNS к backend-ам)
  • tailscale0 (реальные VPN IP клиентов)

Shared namespace и bridge network не взаимоисключающие — sidecar наследует оба интерфейса.

Конфигурация

Tailscale sidecar (tailscale-sidecar.container):

[Container]
ContainerName=tailscale-sidecar
Image=docker.io/tailscale/tailscale:latest
Network=app.network
AddCapability=NET_ADMIN
AddDevice=/dev/net/tun
Environment=TS_STATE_DIR=/var/lib/tailscale
Environment=TS_HOSTNAME=server-private
Environment=TS_EXTRA_ARGS=--login-server=https://vpn.example.org
Environment=TS_NETFILTER_MODE=off
Environment=TS_USERSPACE=false
Volume=tailscale_state:/var/lib/tailscale:Z

[Service]
Restart=always

[Install]
WantedBy=default.target

TS_USERSPACE=false — принудительно использовать TUN-режим. Без этого containerboot может выбрать userspace networking, и интерфейс tailscale0 не появится. TS_NETFILTER_MODE=off подавляет ошибки iptables в rootless-контейнере — они cosmetic и не влияют на работу WireGuard.

Caddy-private (caddy-private.container):

[Unit]
After=tailscale-sidecar.service
Requires=tailscale-sidecar.service

[Container]
ContainerName=caddy-private
Image=caddy:2
Exec=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
Network=tailscale-sidecar.container
Environment=CLOUDFLARE_API_TOKEN=<token>
Volume=./Caddyfile-private:/etc/caddy/Caddyfile:ro,Z
Volume=caddy_private_data:/data:Z
Volume=caddy_private_config:/config:Z

[Service]
Restart=always

[Install]
WantedBy=default.target

Caddyfile-private:

{
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

grafana.example.org {
    reverse_proxy grafana:3000
}

prometheus.example.org {
    reverse_proxy prometheus:9090
}

notes.example.org {
    reverse_proxy silverbullet:3000
}

NET_ADMIN в rootless Podman

Для TUN-интерфейса Tailscale нужен NET_ADMIN и /dev/net/tun. Практическая проверка показала, что в rootless Podman 5.x на Debian 13 TUN создаётся успешно:

$ podman exec tailscale-sidecar ip link show tailscale0
3: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280
    link/none

iptables выдаёт Permission denied — rootless-контейнер не может управлять firewall хоста. Это не критично: WireGuard-туннель работает, firewall управляется на уровне хоста.

Плюсы

  • Физическая изоляция — приватные порты не опубликованы; ошибка в Caddyfile не откроет сервис в интернет
  • auto_https, reload — работают стандартно, без костылей
  • Два простых Caddyfile вместо одного сложного
  • Независимые обновления публичного и приватного Caddy
  • Defense in depth — компрометация публичного Caddy не затрагивает приватные сервисы

Минусы

  • Три контейнера вместо одного (Caddy-public + Tailscale sidecar + Caddy-private)
  • Два набора TLS-сертификатов
  • +30-50 MB RAM (Tailscale + второй Caddy)
  • Дополнительная нода в VPN-координаторе

Сравнение вариантов

КритерийSocket activationДва Caddy
Компоненты1 Caddy + .socket2 Caddy + 1 sidecar
Source IP (публичный)виденпотерян (не нужен)
Source IP (VPN)виденвиден
Inter-container DNSработаетработает
Caddyfile1 файл, все site blocks2 файла, проще каждый
TLS-сертификаты1 набор2 набора
Изоляция приватныхлогическая (remote_ip 403)физическая (порты не опубликованы)
auto_httpsотключенработает
Reloadтолько podman execстандартный
RAM overhead~0+30-50 MB
Дополнительная VPN-ноданетда
Сложность конфигурациисредняя (fd-binding)средняя (shared namespace)
Риск ошибки конфигурацииприватный сервис открытприватный сервис недоступен
Независимые обновлениянетда
Мониторинг1 процесс3 процесса

Реализация: переход на два Caddy

Последовательность действий при переходе на архитектуру с двумя Caddy:

1. Tailscale sidecar

Создать контейнер с Tailscale, подключённый к общей сети backend-ов. Сгенерировать auth key в VPN-координаторе, запустить sidecar, убедиться что tailscale0 создан и нода зарегистрирована. Записать VPN IP новой ноды — он понадобится для DNS.

2. Caddy-private

Создать второй Caddy с Network=tailscale-sidecar.container. Важно: если образ Caddy содержит caddy-docker-proxy или аналогичный плагин, нужно явно переопределить команду на caddy run --config /etc/caddy/Caddyfile --adapter caddyfile — иначе контейнер упадёт с ошибкой подключения к Docker socket.

3. Caddyfile-private

Отдельный Caddyfile с site blocks только для приватных сервисов. TLS через DNS challenge (тот же подход, что у публичного Caddy). Backend-ы доступны по имени контейнера — inter-container DNS работает через shared namespace.

4. Обновить публичный Caddy

Удалить site blocks приватных сервисов из основного Caddyfile. Публичный Caddy обслуживает только интернет-сервисы. Docker-proxy и связанные переменные окружения можно убрать, если они больше не нужны.

5. DNS

Обновить DNS-записи приватных доменов — A и AAAA записи должны указывать на VPN IP sidecar-ноды, не на публичный IP сервера. Записи видны публично, но VPN IP не маршрутизируется в интернете. Не забыть про AAAA-записи — если IPv6 указывает на старый адрес, клиенты с IPv6 получат SSL-ошибку (попадут на публичный Caddy, где приватного домена уже нет).

6. Проверка

  • Публичные сервисы доступны из интернета
  • Приватные сервисы доступны из VPN
  • Приватные сервисы не доступны из интернета (VPN IP не маршрутизируется)
  • SSH-проксирование (Layer4) работает на публичном Caddy

Итоги

Контейнеризация Caddy создаёт неочевидную проблему: custom bridge network, необходимая для inter-container DNS, убивает видимость client IP через SNAT. Matcher remote_ip перестаёт работать, и разделить публичные и приватные сервисы средствами Caddy становится невозможно.

Из девяти рассмотренных вариантов два оказались практичными:

Socket activation — элегантное решение для тех, кто ценит минимализм. Один Caddy, один Caddyfile, source IP для всего трафика. Цена — отключённый auto_https, reload через podman exec, логическая изоляция через matcher.

Два Caddy с Tailscale sidecar — решение для тех, кто ценит безопасность. Физическая изоляция: приватные порты не опубликованы, ошибка конфигурации не приведёт к утечке. Цена — три контейнера, два набора сертификатов, дополнительная VPN-нода.

Выбор зависит от приоритетов. Если главное — простота и полный контроль над IP — socket activation. Если главное — гарантия изоляции и defense in depth — два Caddy. Оба варианта совместимы с rootless Podman, custom bridge network и существующей инфраструктурой backend-сервисов.

Источники