Skip to main content

Subscription Page on the Same Server as the Panel

Source code

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​

ItemValue
Domainsub.example.com
Panelon the same host, container xpanel
API tokencreated in the panel
Docker and Docker Composeinstalled
nginx and certbotinstalled
Local port3010

Create the API token in the panel: Settings → API Tokens → Create token. The value is shown only once — save it.

warning

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​

docker-compose.yml
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​

  1. Put the token into REMNAWAVE_API_TOKEN in /opt/xlada-subpage/.env and restart the container:

    cd /opt/xlada-subpage && docker compose up -d
  2. Set the public subscription address in the panel's /opt/xlada/.env:

    SUB_PUBLIC_DOMAIN=sub.example.com

    The value is without https:// and without a trailing slash. If CUSTOM_SUB_PREFIX=sub is set, use SUB_PUBLIC_DOMAIN=sub.example.com/sub.

  3. Restart the panel so it re-reads the file:

    cd /opt/xlada
    docker compose up -d xpanel
  4. Check the link: Users → select a user → Subscription link. The address must start with https://sub.example.com/: the panel builds it from SUB_PUBLIC_DOMAIN and 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.

SymptomCause and what to do
Empty response, connection dropsX-Forwarded-For or X-Forwarded-Proto: https is missing; check the location block in nginx
502 Bad Gatewaythe container is not running or nginx forwards the request to a port other than 127.0.0.1:3010
401 or 403wrong or revoked REMNAWAVE_API_TOKEN; create a new token
The page times outthe 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 logswrong TRUST_PROXY; set the number of hops in front of the page
404 on the subscription linkCUSTOM_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

What's next​