Subscription Page on a Separate Server
Panel â xray-panel/backend ¡ node â xray-panel/node ¡ subscription page â xray-panel/subscription-page
The subscription page serves the user a styled list of their connections: client
links, QR codes, expiration date, and remaining traffic. It reads data from the panel
by API token. The client opens it at the domain sub.example.com with a short code
in the path.
When you need a separate serverâ
A separate server is used when the subscription page must be isolated from the
panel: the load is distributed between machines, and no extra services appear on the
panel host. The page calls the panel API by the domain
https://panel.example.com, so the panel must be reachable from the internet over
HTTPS. If the panel and the subscription page are on the same host, use
Subscription Page on the Same Server as the Panel.
What you needâ
| Item | Value |
|---|---|
| Domain | sub.example.com |
| Panel | reachable from the outside at https://panel.example.com |
| API token | created in the panel |
| Docker and Docker Compose | on the new server |
| nginx and certbot | on the new server |
| Local port | 3010 |
The token is created in the panel: Settings â API Tokens â Create token. The value is shown only once â save it. This is the same token as when installing on the same server as the panel.
Server preparationâ
sudo apt-get update
sudo apt-get install -y docker.io docker-compose-v2 nginx certbot
sudo systemctl enable --now docker
docker compose version
If the docker-compose-v2 package is not available, install Docker Engine using the
official instructions and check docker compose version.
Environment fileâ
mkdir -p /opt/xlada-subpage && cd /opt/xlada-subpage
cat > .env <<'EOF'
APP_PORT=3010
# Panel is reachable from the outside over HTTPS.
REMNAWAVE_PANEL_URL=https://panel.example.com
REMNAWAVE_API_TOKEN=paste_token_from_panel
# Path prefix if the page is not served at the root. No leading or trailing slashes.
CUSTOM_SUB_PREFIX=
# How many reverse proxy hops to trust when determining the client IP.
TRUST_PROXY=1
# X-Api-Key for external authorization: Caddy with the security addon or Tiny Auth.
CADDY_AUTH_API_TOKEN=
# Cloudflare Zero Trust.
CLOUDFLARE_ZERO_TRUST_CLIENT_ID=""
CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET=""
# Accepting old Marzban links.
MARZBAN_LEGACY_LINK_ENABLED=false
MARZBAN_LEGACY_SECRET_KEY=
MARZBAN_LEGACY_SUBSCRIPTION_VALID_FROM=
EOF
chmod 600 .env
Docker Composeâ
services:
xpanel-subpage:
image: ghcr.io/xray-panel/subscription-page:latest
container_name: xpanel-subpage
restart: unless-stopped
env_file: .env
ports:
- 127.0.0.1:3010:3010
networks:
- xpanel-network
networks:
xpanel-network:
name: xpanel-network
driver: bridge
There is no panel on this server, so the xpanel-network network is created locally.
The page listens on 127.0.0.1:3010 and is not exposed: only nginx provides access to
it. Start the container:
docker compose up -d
docker compose logs -f xpanel-subpage
Certificateâ
Port 80 on the new server is free, so the certificate is issued before nginx is configured:
sudo certbot certonly --standalone -d sub.example.com
Full nginx configâ
# /etc/nginx/sites-available/xlada-subpage
server {
listen 80;
listen [::]:80;
server_name sub.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name sub.example.com;
ssl_certificate /etc/letsencrypt/live/sub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sub.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
sudo ln -sf /etc/nginx/sites-available/xlada-subpage /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-run
TRUST_PROXYâ
TRUST_PROXY is the Express trust proxy setting. The real client address arrives
in the X-Forwarded-For header, which is easy to forge, so the application must be
told which hops to trust.
| Value | When to set |
|---|---|
1 | exactly one proxy in front of the page â the common case |
2 | two hops, for example Cloudflare, then nginx |
true | trust everyone; for debugging on an isolated network only |
false | trust no one, the real IP is not determined |
127.0.0.1, 172.16.0.0/12 | trust the listed addresses and subnets |
List extra hops only if they really exist: excessive trust lets a client spoof someone else's address in the header.
CUSTOM_SUB_PREFIXâ
By default the page is served at the domain root. If you need a path, set
CUSTOM_SUB_PREFIX without leading or trailing slashes and duplicate the same path in
the panel:
CUSTOM_SUB_PREFIX=sub
SUB_PUBLIC_DOMAIN=sub.example.com/sub
Both values must match, otherwise the client gets a link to a non-existent path. You
cannot proxy the page at an arbitrary location: the path is set by this variable,
and nginx still passes the root to 127.0.0.1:3010.
Panel setup and verificationâ
-
Put the token into
REMNAWAVE_API_TOKENin/opt/xlada-subpage/.envand restart the container:cd /opt/xlada-subpage && docker compose up -d -
On the panel server, set the public subscription address in
/opt/xlada/.env:SUB_PUBLIC_DOMAIN=sub.example.comThe value is without
https://and without a trailing slash. IfCUSTOM_SUB_PREFIX=subis set, useSUB_PUBLIC_DOMAIN=sub.example.com/sub. -
Restart the panel so it re-reads the file:
cd /opt/xladadocker compose up -d xpanel
# The container is running and listening on a local port
docker compose ps
docker compose logs --tail 50 xpanel-subpage
# The page responds: expect HTTP/2 200
curl -sI https://sub.example.com/AbCdEf1234567890 | head -n 5
# The panel must respond from the outside
curl -sI https://panel.example.com | head -n 1
A successful response contains HTTP/2 200 and the profile-web-page-url header. In
the panel the link is checked like this: Users â select a user â Subscription link
â the address must start with https://sub.example.com/.
While SUB_PUBLIC_DOMAIN is set to a value like panel.example.com/api/sub, clients
will get the panel's built-in page instead of the one you installed.