Traefik
Traefik is a reverse proxy that obtains Let's Encrypt certificates by itself and
reads configuration from files and Docker labels. It is convenient when access has
to be split: the panel is open only to your addresses, while the subscription path
/api/sub is open to the whole internet.
The static part of the configuration describes entry points, ACME, and providers,
while the dynamic part describes routers, services, and middleware. The file
provider re-reads files on the fly, so routing edits apply without restarting the
container. The docker provider is needed only if you prefer to define routes with
labels on the panel container.
Below is a variant where Traefik stands directly in front of the panel and terminates TLS itself.
1. What you will needâ
- the domain
panel.example.com, whose A record points to the host IP; - open ports
80and443(port80is required for the ACME http-01 challenge); - a running
xpanelpanel on thexpanel-networknetwork; - the
xpanel-subpagesubscription page (port3010), if you use it.
The domain sub.example.com is needed if the subscription page is served separately
from the panel. The domain values must match those specified in the panel .env:
PANEL_DOMAIN, FRONT_END_DOMAIN, and SUB_PUBLIC_DOMAIN.
Check DNS before you start:
dig +short panel.example.com
The command must return your host IP. If the answer is empty, wait for the zone to update at the registrar â ACME will not be able to validate the domain.
If the domain is proxied through Cloudflare (orange cloud), http-01 will not pass. While the certificate is issued for the first time, switch the record to "DNS only" mode.
2. Directory and networkâ
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
Create the network once and connect the panel to it:
docker network create xpanel-network
docker network connect xpanel-network xpanel
docker network connect xpanel-network xpanel-subpage
Traefik talks to the panel by the name xpanel:3000 and does not use the published
ports 127.0.0.1:3000. The port mappings from the panel docker-compose.yml can be
left in place â they are useful for local diagnostics.
Traefik runs on the same bridge network as the panel, so it sees it by container
name. Only ports 80 and 443 are published; ports 3000, 3010, and 3001
remain reachable only on the host itself.
If you install Traefik as a package rather than as a container, put the static
config in /etc/traefik/traefik.yml, the dynamic one in /etc/traefik/config/, and
start it with a systemd unit:
[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 for Traefikâ
The container listens on 80 and 443, reads the Docker socket read-only, stores
certificates in the letsencrypt directory, and writes logs to logs. Create the
logs directory in advance, otherwise Docker will create it as 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:
# needed to read container labels
- /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. Static configâ
The traefik.yml file is read once when the container starts. If you change entry
points or ACME, restart Traefik. It describes the web entry point itself with a
redirect to websecure, trusted proxies, providers, and certificate parameters.
api:
dashboard: true
# Listen only on loopback inside the container: the dashboard is not exposed
insecure: false
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ':443'
# Trust X-Forwarded-* headers only from these sources.
# Without this list a client can spoof X-Forwarded-For and bypass 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:
# Internal traffic to the panel goes over HTTP, no certificate check is needed
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. Dynamic configâ
The file describes the routers of the panel and the subscription page. It also sets
the security headers, the IP restriction, and the upstream service addresses. The
Host rule must match exactly the domain the certificate is issued for.
http:
routers:
# Subscription page â public traffic without restrictions
xlada-sub:
rule: 'Host(`sub.example.com`)'
entryPoints:
- websecure
service: subpage
tls:
certResolver: letsencrypt
# Build assets: long cache based on the hash in the file name
xlada-assets:
rule: 'Host(`panel.example.com`) && PathPrefix(`/assets/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-static-cache
service: panel
tls:
certResolver: letsencrypt
priority: 100
# Panel: only your IP, plus security headers
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
# Traffic arrives at websecure, so Traefik sets
# X-Forwarded-Proto: https itself â the panel accepts such a request
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 means: from the X-Forwarded-For chain take the address
# one position away from the trusted proxy and treat it as the client
depth: 1
services:
panel:
loadBalancer:
servers:
- url: 'http://xpanel:3000'
subpage:
loadBalancer:
servers:
- url: 'http://xpanel-subpage:3010'
6. Launchâ
cd /opt/xlada/traefik
docker compose up -d
docker compose logs -f
The certificate will appear in letsencrypt/acme.json. The file must stay private:
chmod 600. If you see an ACME error in the logs, check that port 80 is reachable
from outside and that the DNS record is not yet proxied through a CDN.
7. Public subscription pathâ
In the configuration above the panel is entirely closed by the IP list, while the
subscription page is open on a separate domain. If the subscription page is not
deployed, only the path /api/sub/ on the panel domain must remain public: this
address goes into the profile-web-page-url header, and clients download the
subscription from it.
For this, two routers are declared on one domain: one for /api/sub/ without
restrictions, the second for everything else â with the IP list. Traefik picks the
router with the longer matched prefix, so the declaration order does not matter, and
the rules must overlap only on that path.
http:
routers:
# Subscription page on a separate domain â public traffic
xlada-sub:
rule: 'Host(`sub.example.com`)'
entryPoints:
- websecure
service: subpage
tls:
certResolver: letsencrypt
# Public subscription path on the panel domain
xlada-sub-path:
rule: 'Host(`panel.example.com`) && PathPrefix(`/api/sub/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
service: panel
tls:
certResolver: letsencrypt
priority: 110
# Build assets: long cache based on the hash in the file name
xlada-assets:
rule: 'Host(`panel.example.com`) && PathPrefix(`/assets/`)'
entryPoints:
- websecure
middlewares:
- xlada-headers
- xlada-static-cache
service: panel
tls:
certResolver: letsencrypt
priority: 100
# Panel: only your IP, plus security headers
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'
After the edit, check both scenarios: a request to /api/sub/AbCdEf1234567890 must
return a response without 403, while the panel root must remain unreachable from a
foreign address.
Keep in mind that the SUB_PUBLIC_DOMAIN value in the panel .env must match the
chosen scheme: either sub.example.com or panel.example.com/api/sub.
8. Labels for the panel containerâ
The same routing can be described with labels instead of a file. In this variant
only middlewares remain in xlada.yml, while Traefik reads routers and services from
the container labels. Below are both files in full.
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'
# Public subscription path
- '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'
# Build assets with a long cache
- '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'
# Panel: only your 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
Do not use a file and labels for the same router at the same time â that produces a
definition conflict. In this variant, routers and services are described only by
labels, and the xlada.yml file contains nothing but middlewares.
9. Real client IPâ
The correct IP is needed for HWID limits, audit, and login brute-force protection.
Traefik sets X-Forwarded-For, but trusts the incoming header only from addresses in
forwardedHeaders.trustedIPs.
Let us break down the chain with a concrete example. Client A goes through proxy
B to Traefik T:
X-Forwarded-For: A, B
depth: 1â take the address one position away from the trusted proxy (in the example this isA), that is, the real client;excludedIPsâ drops the listed addresses from the chain beforehand; this method is used when Cloudflare stands in front of Traefik: the list contains the Cloudflare ranges, and Traefik sees the client rather than the CDN address.
If the panel sees the same address, check:
- the Docker network range and Cloudflare addresses are added to
trustedIPs; ipStrategyhas the correctdepthorexcludedIPslist;- the header is not overwritten by an intermediate proxy.
If Cloudflare stands in front of Traefik, additionally configure trust for
CF-Connecting-IP as described on the
Cloudflare Tunnel page.
10. Verification and diagnosticsâ
# Check the certificate and the redirect
curl -sI http://panel.example.com | head -3
curl -sI https://panel.example.com | head -3
# The public subscription path must answer without an IP restriction
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/
# See which IP Traefik observes
tail -n 20 /opt/xlada/traefik/logs/access.log
docker compose logs traefik | tail -50
The Traefik dashboard is enabled but not published externally. You can reach it like
this. The socat daemon forwards the container loopback port to the host, and then a
regular SSH tunnel does the rest:
# Expose port 8080 of the traefik container on the host localhost
socat TCP-LISTEN:8080,fork,reuseaddr \
TCP:$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' traefik):8080
# On your machine
ssh -L 8080:127.0.0.1:8080 user@panel.example.com
After that, open http://127.0.0.1:8080/dashboard/ in a browser. If the dashboard is
needed permanently, it is safer to close it with a separate router with ipAllowList
and basicAuth than to publish it without protection.
Instead of a hard-coded IP list, you can close the panel via Cloudflare Zero Trust or a VPN. An address list is reliable only if the administrator has a static IP.