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

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:

/etc/systemd/system/traefik.service
[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.

/opt/xlada/traefik/docker-compose.yml
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, доверенные прокси, провайдеры и параметры сертификатов.

/opt/xlada/traefik/traefik.yml
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 должно точно совпадать с доменом, для которого выпускается сертификат.

/opt/xlada/traefik/config/xlada.yml
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 выбирает роутер с более длинным совпавшим префиксом, поэтому порядок объявления не важен, а правила должны пересекаться только по этому пути.

/opt/xlada/traefik/config/xlada.yml
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 читает из меток контейнеров. Ниже — оба файла целиком.

/opt/xlada/traefik/config/xlada.yml
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
/opt/xlada/docker-compose.yml
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 у администратора.

Что дальше​