almdev Технический блог

Traefik и локальный HTTPS на доменах .test
Один прокси на все проекты вместо порта на каждый.

Настройка Traefik v3 с файловым провайдером для локальной разработки: свои сертификаты для .test, динамические конфигурации проектов, заголовки проксирования и разбор маршрута, который не поднялся.

Подкатегория: Docker

Чтение
5 мин
Технологии / версии
Docker · Traefik · TLS · nginx
12 фев 2026 · 5 мин · 5 просмотров · Ops
Защищённый маршрутизатор соединяет локальные сервисы под стеклянными колпаками
Docker 02/2026

Двенадцать проектов на портах с 8080 по 8091. Закладки называются «8083 — это который?». Половина браузерных возможностей требует защищённого контекста и не работает. Куки с флагом Secure не ставятся, а без него не воспроизвести поведение прода.

Один обратный прокси решает всё это разом и настраивается за вечер.

Прокси вместо публикации портов

Идея: ни один проект не публикует порты на хосте. Внешние порты 80 и 443 занимает только прокси. Проекты подключаются к его сети, и он раздаёт их по доменным именам.

Ниже сокращённый фрагмент конфигурации; логирование и ограничения ресурсов опущены. Версия 3.1 закреплена для воспроизводимости этого примера, а не как рекомендация новой установки: перед обновлением внутри ветки 3.x я сверяю изменившиеся параметры с документацией.

services:
  traefik:
    image: traefik:v3.1
    container_name: dev-traefik
    restart: unless-stopped
    command:
      - --api.dashboard=true
      - --api.insecure=true
      - --providers.file.directory=/etc/traefik/dynamic/projects
      - --providers.file.watch=true
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.forwardedHeaders.insecure=true
    ports:
      - "80:80"
      - "443:443"
      - "127.0.0.1:8080:8080"
    volumes:
      - ./dynamic:/etc/traefik/dynamic:ro
      - ./certs:/certs:ro
    networks:
      - proxy-dev
      - mail-dev

Флаг api.insecure открывает dashboard на порту 8080 без аутентификации, поэтому порт привязан только к 127.0.0.1. Это локальное упрощение; для доступного по сети dashboard нужен отдельный router к api@internal и защита.

Конфликты портов приложений исчезают как класс. Можно поднять все двенадцать проектов одновременно, и они не подерутся.

Файловый провайдер, а не метки Docker

Traefik умеет читать настройки прямо из меток контейнеров, и почти все примеры показывают именно это. Я выбрал файлы, и вот почему.

Метки живут в docker-compose.yml проекта. Значит, каждый проект знает про прокси, про имя сети и про свой домен. При копировании набора в новый проект нужно поправить четыре метки, и рано или поздно одну из них я забуду.

С файловым провайдером проект не знает о прокси ничего. Он просто подключает nginx к общей сети. Вся маршрутизация описана снаружи, в каталоге прокси:

# dynamic/projects/teamspace.yml
http:
  routers:
    teamspace:
      rule: "Host(`teamspace.test`)"
      entryPoints: [websecure]
      service: teamspace
      tls: {}

  services:
    teamspace:
      loadBalancer:
        servers:
          - url: "http://teamspace-app_nginx:80"

Небольшой файл на проект лежит в одном месте. Видно все маршруты сразу, и добавление проекта не требует его пересборки.

Флаг наблюдения за каталогом означает, что новый файл подхватывается за секунду. Перезапускать прокси не нужно.

Сертификаты для .test

Домен .test зарезервирован стандартом для тестирования и не может быть делегирован в обычном DNS. Это отличает его от .dev, который принадлежит Google и принудительно требует HTTPS, и от .local, конфликтующего с mDNS.

Сертификат выпускается собственным центром сертификации. Порядок такой: сначала корневой сертификат, потом сертификат на подстановочное имя, потом установка корневого в систему.

brew install openssl@3
OPENSSL="$(brew --prefix openssl@3)/bin/openssl"

# корневой центр — один раз
"$OPENSSL" req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
  -keyout certs/rootCA.key -out certs/rootCA.crt \
  -subj "/CN=Dev Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

# сертификат на *.test
"$OPENSSL" req -newkey rsa:2048 -nodes -keyout certs/test.key -out certs/test.csr \
  -subj "/CN=*.test"

"$OPENSSL" x509 -req -in certs/test.csr -CA certs/rootCA.crt -CAkey certs/rootCA.key \
  -CAcreateserial -out certs/test.crt -days 825 -sha256 \
  -extfile <(printf "subjectAltName=DNS:*.test,DNS:test\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n")

Расширение с альтернативными именами обязательно: современные браузеры не используют Common Name как замену SAN. Для TLS-сертификата также явно задано назначение serverAuth, которого требует проверка Apple.

Срок 825 дней здесь — не универсальный браузерный предел, а выбранный срок для локального сертифика от собственного доверенного центра. Ограничения для публично доверенных TLS-сертификатов на такую локальную CA не переносятся.

Корневой сертификат ставится в системное хранилище:

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain certs/rootCA.crt

Современный Firefox на macOS умеет автоматически доверять сторонним корневым сертификатам из системного хранилища, но настройка может быть отключена. Если сертификат не принят, сначала проверяю параметр автоматического доверия сторонним root CA; отдельный импорт нужен только когда эта интеграция не используется.

Разрешение имён

Домены .test должны куда-то указывать. Два варианта.

Простой — по строке в /etc/hosts на каждый проект. Работает, требует правки при добавлении проекта, не поддерживает поддомены.

Правильный — локальный резолвер, отдающий все .test на петлевой адрес. На macOS я использую dnsmasq. Сначала он должен слушать локальный адрес и отвечать за зону:

brew install dnsmasq
echo 'address=/.test/127.0.0.1' >> "$(brew --prefix)/etc/dnsmasq.conf"
sudo brew services restart dnsmasq

Затем macOS направляет запросы зоны .test этому серверу через /etc/resolver/test:

nameserver 127.0.0.1

После этого любое имя в зоне .test, включая выдуманное на ходу, разрешается автоматически. Это относится только к DNS: сертификат *.test покрывает teamspace.test, но не acme.teamspace.test. Для мультиарендных поддоменов нужен дополнительный SAN вроде *.teamspace.test или отдельный сертификат проекта.

Заголовки проксирования

Здесь приложение часто начинает собирать неверные ссылки.

PHP за прокси видит запрос как HTTP на порт 80. Генератор URL честно собирает http://teamspace-app_nginx/... — то есть внутреннее имя контейнера по незащищённому протоколу. Ссылки в письмах ведут в никуда, редиректы сбрасывают на HTTP, куки с флагом Secure не ставятся.

Лечится с двух сторон. Прокси передаёт заголовки о протоколе и хосте, а приложение им доверяет:

# Symfony, config/packages/framework.yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port']

В переменной — подсеть Docker, а не конкретный адрес: он меняется при пересоздании сети.

Для Laravel то же самое делается через промежуточный слой доверенных прокси с указанием подсети.

Флаг forwardedHeaders.insecure в командной строке Traefik — только для локальной среды. На проде он означает доверие любому источнику заголовков, что позволяет подделать адрес клиента.

Когда маршрут не поднялся

Дашборд на порту 8080 показывает все маршрутизаторы и сервисы со статусом. Порядок разбора у меня такой.

Маршрута нет в списке вовсе — Traefik не прочитал файл. Обычно ошибка синтаксиса YAML; в логах прокси она видна одной строкой.

Маршрут ест, но помечен как ошибочный — не найден сервис. Причина обычно в опечатке в имени или ссылке на несуществующий сервис.

Маршрут в порядке, но браузер отдаёт «плохой шлюз» — прокси не достучался до контейнера. Причин две: контейнер не в общей сети или nginx внутри не слушает порт 80.

docker exec dev-traefik wget -qO- http://teamspace-app_nginx:80 | head -5

Одна команда, и понятно, на чьей стороне проблема.

Отдельный случай: браузер ругается на сертификат для одного проекта, а для остальных всё хорошо. Значит, у маршрута не указан раздел TLS и он отвечает по HTTP на защищённой точке входа.

Чего это стоит

Один постоянно работающий контейнер, около 40 мегабайт памяти. Небольшой файл маршрута на каждый проект. Разовая настройка сертификатов.

Взамен: адреса вида teamspace.test вместо портов, рабочий HTTPS со всеми браузерными возможностями, поведение куки как на проде и возможность держать поднятыми все проекты сразу.

Единственное, к чему пришлось привыкнуть, — проект без записи в каталоге прокси просто недоступен. Забыл добавить файл, а nginx поднялся и логов не пишет, потому что до него никто не дошёл.

#docker #traefik #tls #nginx