Traefik
Traefik — обратный прокси, который сам получает сертификаты Let's Encrypt и
читает конфигурацию из файлов и меток Docker. Он удобен, когда нужно разделить
доступ: панель открыта только вашим адресам, а путь подписки /api/sub —
всему интернету.
Статическая часть конфигурации описывает точки входа, ACME и провайдеров, а
динамическая — роутеры, сервисы и middleware. Провайдер file перечитывает
файлы на лету, поэтому правки в маршрутизации применяются без перезапуска
контейнера. Провайдер docker нужен только в том случае, если вы предпочитаете
задавать маршруты метками на контейнере панели.
Ниже — вариант, при котором Traefik стоит непосредственно перед панелью и сам терминирует TLS.
1. Что понадобится
- домен
panel.example.com, A-запись которого указывает на IP хоста; - открытые порты
80и443(порт80обязателен для ACME http-01); - запущенная панель
xpanelв сетиxpanel-network; - страница подписки
xpanel-subpage(порт3010), если вы её используете.
Домен sub.example.com понадобится, если страница подписки отдаётся отдельно
от панели. Значения доменов должны совпадать с теми, что указаны в .env
панели: PANEL_DOMAIN, FRONT_END_DOMAIN и SUB_PUBLIC_DOMAIN.
Проверьте DNS до начала настройки:
dig +short panel.example.com
Команда должна вернуть IP вашего хоста. Если ответ пустой, дождитесь обновления зоны у регистратора — ACME не сможет подтвердить домен.
Если домен проксируется через Cloudflare (оранжевое облако), http-01 не пройдёт. На время первого выпуска сертификата переключите запись в режим «только DNS».
2. Каталог и сеть
mkdir -p /opt/xlada/traefik/config /opt/xlada/traefik/letsencrypt /opt/xlada/traefik/logs
cd /opt/xlada/traefik
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
Создайте сеть один раз и подключите к ней панель:
docker network create xpanel-network
docker network connect xpanel-network xpanel
docker network connect xpanel-network xpanel-subpage
Traefik общается с панелью по имени xpanel:3000 и не использует публикацию
портов 127.0.0.1:3000. Проброс портов из docker-compose.yml панели можно
оставить — он пригодится для локальной диагностики.
Traefik работает в той же bridge-сети, что и панель, поэтому видит её по имени
контейнера. Публикуются только порты 80 и 443; порты 3000, 3010 и
3001 остаются доступными лишь на самом хосте.
Если вы ставите Traefik пакетом, а не контейнером, положите статический конфиг
в /etc/traefik/traefik.yml, динамический — в /etc/traefik/config/, и
запустите его юнитом systemd:
[Unit]
Description=Traefik
After=network-online.target
Wants=network-online.target
[Service]
Restart=on-failure
ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now traefik
3. docker-compose для Traefik
Контейнер слушает 80 и 443, читает сокет Docker только для чтения, хранит
сертификаты в каталоге letsencrypt и пишет логи в logs. Каталог logs
создайте заранее, иначе Docker создаст его от имени root.
services:
traefik:
image: traefik:v3.5
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- xpanel-network
ports:
- '0.0.0.0:80:80'
- '0.0.0.0:443:443'
environment:
- TZ=UTC
volumes:
# нужен для чтения меток контейнеров
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./config:/config:ro
- ./letsencrypt:/letsencrypt
- ./logs:/var/log/traefik
networks:
xpanel-network:
name: xpanel-network
external: true
4. Статический конфиг
Файл traefik.yml читается один раз при старте контейнера. Если меняете точки
входа или ACME — перезапустите Traefik. Здесь описаны сама точка входа web
с редиректом на websecure, доверенные прокси, провайдеры и параметры
сертификатов.
api:
dashboard: true
# Слушаем только loopback внутри контейнера: наружу дашборд не смотрит
insecure: false
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ':443'
# Доверяем заголовкам X-Forwarded-* только от этих источников.
# Без этого списка клиент может подделать X-Forwarded-For и обойти ipAllowList.
forwardedHeaders:
trustedIPs:
- '172.16.0.0/12'
- '173.245.48.0/20'
- '103.21.244.0/22'
- '104.16.0.0/13'
- '172.64.0.0/13'
serversTransport:
# Внутренний трафик до панели идёт по HTTP, проверка сертификата не нужна
insecureSkipVerify: true
providers:
docker:
exposedByDefault: false
network: xpanel-network
file:
directory: /config
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
log:
level: INFO
filePath: /var/log/traefik/traefik.log
accessLog:
filePath: /var/log/traefik/access.log
5. Динамический конфиг
Файл описывает роутеры панели и страницы подписки. Здесь же заданы защитные
заголовки, ограничение по IP и адреса upstream-сервисов. Правило Host должно
точно совпадать с доменом, для которого выпускается сертификат.
http:
routers:
# Страница подписки — публичный трафик без ограничений
xlada-sub:
rule: 'Host(`sub.example.com`)'
entryPoints:
- websecure
service: subpage
tls:
certResolver: letsencrypt
# Статика сборки: длинный кэш по хешу в имени файла
xlada-assets:
rule: 'Host(`panel.example.com`) && PathPrefix(`/assets/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-static-cache
service: panel
tls:
certResolver: letsencrypt
priority: 100
# Панель: только ваш IP, плюс защитные заголовки
xlada-panel:
rule: 'Host(`panel.example.com`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-ip-allow
service: panel
tls:
certResolver: letsencrypt
middlewares:
xlada-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: strict-origin-when-cross-origin
# Трафик приходит на websecure, поэтому Traefik сам проставляет
# X-Forwarded-Proto: https — панель принимает такой запрос
xlada-static-cache:
headers:
customResponseHeaders:
Cache-Control: 'public, max-age=31536000, immutable'
xlada-ip-allow:
ipAllowList:
sourceRange:
- '203.0.113.10/32'
- '198.51.100.0/24'
ipStrategy:
# depth = 1 означает: в цепочке X-Forwarded-For берём адрес
# на одну позицию от доверенного прокси и считаем его клиентским
depth: 1
services:
panel:
loadBalancer:
servers:
- url: 'http://xpanel:3000'
subpage:
loadBalancer:
servers:
- url: 'http://xpanel-subpage:3010'
6. Запуск
cd /opt/xlada/traefik
docker compose up -d
docker compose logs -f
Сертификат появится в letsencrypt/acme.json. Файл должен оставаться
закрытым: chmod 600. Если в логах видно ошибку ACME, проверьте, что порт 80
доступен извне и что DNS-запись ещё не проксируется через CDN.
7. Публичный путь подписки
В конфигурации выше панель целиком закрыта списком IP, а страница подписки
открыта на отдельном домене. Если страница подписки не развёрнута, публичным
должен остаться только путь /api/sub/ на домене панели: этот адрес попадает
в заголовок profile-web-page-url и по нему клиенты скачивают подписку.
Для этого на одном домене объявляют два роутера: один для /api/sub/ без
ограничений, второй для всего остального — со списком IP. Traefik выбирает
роутер с более длинным совпавшим префиксом, поэтому порядок объявления не
важен, а правила должны пересекаться только по этому пути.
http:
routers:
# Страница подписки на отдельном домене — публичный трафик
xlada-sub:
rule: 'Host(`sub.example.com`)'
entryPoints:
- websecure
service: subpage
tls:
certResolver: letsencrypt
# Публичный путь подписки на домене панели
xlada-sub-path:
rule: 'Host(`panel.example.com`) && PathPrefix(`/api/sub/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
service: panel
tls:
certResolver: letsencrypt
priority: 110
# Статика сборки: длинный кэш по хешу в имени файла
xlada-assets:
rule: 'Host(`panel.example.com`) && PathPrefix(`/assets/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-static-cache
service: panel
tls:
certResolver: letsencrypt
priority: 100
# Панель: только ваш IP, плюс защитные заголовки
xlada-panel:
rule: 'Host(`panel.example.com`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-ip-allow
service: panel
tls:
certResolver: letsencrypt
middlewares:
xlada-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: strict-origin-when-cross-origin
xlada-static-cache:
headers:
customResponseHeaders:
Cache-Control: 'public, max-age=31536000, immutable'
xlada-ip-allow:
ipAllowList:
sourceRange:
- '203.0.113.10/32'
- '198.51.100.0/24'
ipStrategy:
depth: 1
services:
panel:
loadBalancer:
servers:
- url: 'http://xpanel:3000'
subpage:
loadBalancer:
servers:
- url: 'http://xpanel-subpage:3010'
После правки проверьте оба сценария: запрос к /api/sub/AbCdEf1234567890
должен вернуть ответ без 403, а корень панели — остаться недоступным с
чужого адреса.
Помните, что значение SUB_PUBLIC_DOMAIN в .env панели должно совпадать с
выбранной схемой: либо sub.example.com, либо panel.example.com/api/sub.
8. Метки для контейнера панели
Ту же маршрутизацию можно описать метками вместо файла. В этом варианте в
xlada.yml остаются только middlewares, а роутеры и сервисы Traefik читает из
меток контейнеров. Ниже — оба файла целиком.
http:
middlewares:
xlada-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: strict-origin-when-cross-origin
xlada-static-cache:
headers:
customResponseHeaders:
Cache-Control: 'public, max-age=31536000, immutable'
xlada-ip-allow:
ipAllowList:
sourceRange:
- '203.0.113.10/32'
- '198.51.100.0/24'
ipStrategy:
depth: 1
services:
xpanel-db:
image: postgres:18.4
container_name: xpanel-db
restart: unless-stopped
env_file: .env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- TZ=UTC
volumes:
- xpanel-db-data:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
networks:
- xpanel-network
xpanel-redis:
image: valkey/valkey:9-alpine
container_name: xpanel-redis
restart: unless-stopped
command: >
valkey-server --save "" --appendonly no
--maxmemory-policy noeviction --loglevel warning
healthcheck:
test: ['CMD', 'valkey-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
networks:
- xpanel-network
xpanel:
image: ghcr.io/xray-panel/backend:latest
container_name: xpanel
restart: unless-stopped
env_file: .env
environment:
- REDIS_HOST=xpanel-redis
- REDIS_PORT=6379
- REDIS_DB=0
depends_on:
xpanel-db:
condition: service_healthy
xpanel-redis:
condition: service_healthy
networks:
- xpanel-network
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=xpanel-network'
# Публичный путь подписки
- 'traefik.http.routers.xlada-sub.rule=Host(`panel.example.com`) && PathPrefix(`/api/sub/`)'
- 'traefik.http.routers.xlada-sub.entrypoints=websecure'
- 'traefik.http.routers.xlada-sub.tls.certresolver=letsencrypt'
- 'traefik.http.routers.xlada-sub.middlewares=xlada-headers@file'
- 'traefik.http.routers.xlada-sub.service=xlada'
- 'traefik.http.routers.xlada-sub.priority=110'
# Статика сборки с длинным кэшем
- 'traefik.http.routers.xlada-assets.rule=Host(`panel.example.com`) && PathPrefix(`/assets/`)'
- 'traefik.http.routers.xlada-assets.entrypoints=websecure'
- 'traefik.http.routers.xlada-assets.tls.certresolver=letsencrypt'
- 'traefik.http.routers.xlada-assets.middlewares=xlada-headers@file,xlada-static-cache@file'
- 'traefik.http.routers.xlada-assets.service=xlada'
- 'traefik.http.routers.xlada-assets.priority=100'
# Панель: только ваш IP
- 'traefik.http.routers.xlada-panel.rule=Host(`panel.example.com`)'
- 'traefik.http.routers.xlada-panel.entrypoints=websecure'
- 'traefik.http.routers.xlada-panel.tls.certresolver=letsencrypt'
- 'traefik.http.routers.xlada-panel.middlewares=xlada-headers@file,xlada-ip-allow@file'
- 'traefik.http.routers.xlada-panel.service=xlada'
- 'traefik.http.services.xlada.loadbalancer.server.port=3000'
xpanel-subpage:
image: ghcr.io/xray-panel/subscription-page:latest
container_name: xpanel-subpage
restart: unless-stopped
env_file: .env.subpage
depends_on:
- xpanel
networks:
- xpanel-network
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=xpanel-network'
- 'traefik.http.routers.xlada-subdomain.rule=Host(`sub.example.com`)'
- 'traefik.http.routers.xlada-subdomain.entrypoints=websecure'
- 'traefik.http.routers.xlada-subdomain.tls.certresolver=letsencrypt'
- 'traefik.http.routers.xlada-subdomain.middlewares=xlada-headers@file'
- 'traefik.http.routers.xlada-subdomain.service=xlada-subpage'
- 'traefik.http.services.xlada-subpage.loadbalancer.server.port=3010'
networks:
xpanel-network:
name: xpanel-network
external: true
volumes:
xpanel-db-data:
name: xpanel-db-data
Одновременно файл и метки для одного роутера не используйте — получится
конфликт определений. В этом варианте роутеры и сервисы описаны только метками,
а файл xlada.yml содержит исключительно middlewares.
9. Реальный IP клиента
Корректный IP нужен для HWID-лимитов, аудита и защиты входа от перебора.
Traefik подставляет X-Forwarded-For, но доверяет входному заголовку только от
адресов из forwardedHeaders.trustedIPs.
Разберите цепочку на конкретном примере. Клиент A идёт через прокси B к
Traefik T:
X-Forwarded-For: A, B
depth: 1— берём адрес на одну позицию от доверенного прокси (в примере этоA), то есть реального клиента;excludedIPs— предварительно выбрасывает из цепочки перечисленные адреса; этот способ применяют, когда перед Traefik стоит Cloudflare: в списке перечисляют диапазоны Cloudflare, и Traefik видит клиента, а не адрес CDN.
Если панель видит один и тот же адрес, проверьте:
- диапазон Docker-сети и адреса Cloudflare добавлены в
trustedIPs; - в
ipStrategyуказан правильныйdepthили списокexcludedIPs; - заголовок не перезаписывается промежуточным прокси.
Если перед Traefik стоит Cloudflare, дополнительно настройте доверие к
CF-Connecting-IP так, как описано на странице
Cloudflare Tunnel.
10. Проверка и диагностика
# Проверка сертификата и редиректа
curl -sI http://panel.example.com | head -3
curl -sI https://panel.example.com | head -3
# Публичный путь подписки должен отвечать без ограничения по IP
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/
# Смотрим, какой IP видит Traefik
tail -n 20 /opt/xlada/traefik/logs/access.log
docker compose logs traefik | tail -50
Дашборд Traefik включён, но наружу не опубликован. Добраться до него можно так.
Демон socat прокинет loopback-порт контейнера на хост, а дальше работает
обычный SSH-туннель:
# Показываем порт 8080 контейнера traefik на localhost хоста
socat TCP-LISTEN:8080,fork,reuseaddr \
TCP:$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' traefik):8080
# На своей машине
ssh -L 8080:127.0.0.1:8080 user@panel.example.com
После этого откройте http://127.0.0.1:8080/dashboard/ в браузере. Если
дашборд нужен постоянно, безопаснее закрыть его отдельным роутером с
ipAllowList и basicAuth, чем публиковать без защиты.
Вместо жёсткого списка IP можно закрыть панель через Cloudflare Zero Trust или VPN. Список адресов надёжен только при статическом IP у администратора.