Subscription Page on the Same Server as the Panel
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 and does not expose the panel's internals to the browser. The client
opens it at the domain sub.example.com with a short code in the path.
This option is for the case when the panel and the subscription page are on the same host. The containers connect over the internal Docker network, and only nginx faces outward. If the panel and the subscription page must live on different machines, go to Subscription Page on a Separate Server.
When you need a subscription pageâ
You need your own subscription page if you want a separate subdomain
sub.example.com, your own design, and updates independent of the panel. The panel
can serve a built-in page, but it looks different and lives on the panel's domain.
While SUB_PUBLIC_DOMAIN is set to a value like panel.example.com/api/sub, clients
get the built-in page instead of the one you installed.
What you needâ
| Item | Value |
|---|---|
| Domain | sub.example.com |
| Panel | on the same host, container xpanel |
| API token | created in the panel |
| Docker and Docker Compose | installed |
| nginx and certbot | installed |
| Local port | 3010 |
Create the API token in the panel: Settings â API Tokens â Create token. The value is shown only once â save it.
Both the panel and the subscription page drop the connection if a request arrives
without the X-Forwarded-For and X-Forwarded-Proto: https headers. So a reverse proxy
with TLS is required in front of the page.
Environment fileâ
Create the directory and the environment file:
mkdir -p /opt/xlada-subpage && cd /opt/xlada-subpage
cat > .env <<'EOF'
APP_PORT=3010
# Panel on the shared Docker network â addressed by container name.
REMNAWAVE_PANEL_URL=http://xpanel:3000
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
The REMNAWAVE_* names are kept for compatibility â this way the page also works with
an older Remnawave panel. Leave empty variables as they are.
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
external: true
The xpanel-network network is already created by the panel, so here it is attached as
external. Start the container:
docker compose up -d
docker compose logs -f xpanel-subpage
The page listens on 127.0.0.1:3010 and is not exposed: only nginx provides access to
it.
Certificateâ
First create a temporary block on port 80 that serves the certbot challenge:
sudo mkdir -p /var/www/certbot
sudo nano /etc/nginx/sites-available/xlada-subpage
# /etc/nginx/sites-available/xlada-subpage
# Temporary block for issuing the certificate.
server {
listen 80;
listen [::]:80;
server_name sub.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
sudo ln -sf /etc/nginx/sites-available/xlada-subpage /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt-get update && sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/certbot -d sub.example.com
Full nginx configâ
Replace the file /etc/nginx/sites-available/xlada-subpage with the full 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 nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-run
Domain ownership verification is also needed for renewal, so do not remove the
/var/www/certbot path from the config. Other proxies are covered in
Reverse Proxy.
Panel setupâ
-
Put the token into
REMNAWAVE_API_TOKENin/opt/xlada-subpage/.envand restart the container:cd /opt/xlada-subpage && docker compose up -d -
Set the public subscription address in the panel's
/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 -
Check the link: Users â select a user â Subscription link. The address must start with
https://sub.example.com/: the panel builds it fromSUB_PUBLIC_DOMAINand the user's short identifier.
TRUST_PROXY and CUSTOM_SUB_PREFIXâ
TRUST_PROXY=1 means there is exactly one reverse proxy in front of the page. If
there are more hops, the value changes: the table of values is on the
Separate Server page.
CUSTOM_SUB_PREFIX sets the path if the page is not served at the domain root:
CUSTOM_SUB_PREFIX=sub
SUB_PUBLIC_DOMAIN=sub.example.com/sub
Both values are set without slashes and must match. For details, see the Separate Server page.
Verification and common errorsâ
# 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
A successful response contains HTTP/2 200 and the profile-web-page-url header.
| Symptom | Cause and what to do |
|---|---|
| Empty response, connection drops | X-Forwarded-For or X-Forwarded-Proto: https is missing; check the location block in nginx |
502 Bad Gateway | the container is not running or nginx forwards the request to a port other than 127.0.0.1:3010 |
401 or 403 | wrong or revoked REMNAWAVE_API_TOKEN; create a new token |
| The page times out | the panel is unreachable from the container: a typo in the xpanel name or the xpanel-network network is not connected |
| Wrong client address in the logs | wrong TRUST_PROXY; set the number of hops in front of the page |
404 on the subscription link | CUSTOM_SUB_PREFIX and SUB_PUBLIC_DOMAIN do not match |
Container and nginx logs:
docker compose logs -f xpanel-subpage
sudo tail -f /var/log/nginx/error.log