Skip to main content

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 80 and 443 (port 80 is required for the ACME http-01 challenge);
  • a running xpanel panel on the xpanel-network network;
  • the xpanel-subpage subscription page (port 3010), 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.

warning

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
note

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:

/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 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.

/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:
# 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.

/opt/xlada/traefik/traefik.yml
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.

/opt/xlada/traefik/config/xlada.yml
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.

/opt/xlada/traefik/config/xlada.yml
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.

/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'

# 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 is A), 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;
  • ipStrategy has the correct depth or excludedIPs list;
  • 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.

tip

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.

What's next​