nginx
Overviewâ
nginx is installed in front of the XLADA panel and solves three tasks: it accepts
traffic on ports 80 and 443, terminates TLS, and passes panel requests to
127.0.0.1:3000, adding the headers by which the panel understands that the
connection is secure and who the real client is.
Proxying is mandatory, not optional: the panel and subscription page code
contains middleware that drops the connection if a request arrives without
the X-Forwarded-For and X-Forwarded-Proto: https headers. Therefore the panel
must already be up before you install nginx â see
Panel installation, and for the second host,
Subscription page.
You will need a registered domain and access to its DNS records. Without a domain you cannot issue a certificate, and without HTTPS the panel will not work.
DNS: point the domain to the serverâ
Find out the server's public address. A static IPv4 address is preferable. Then create records at your registrar or DNS provider:
| Type | Name | Value | Purpose |
|---|---|---|---|
A | panel | server IPv4 address | panel and API |
AAAA | panel | server IPv6 address | the same over IPv6, if available |
A | sub | server IPv4 address | subscription page |
If the panel should live on the domain itself, enter @ in the name field.
Create AAAA records only when IPv6 is actually configured on the server:
otherwise clients will go nowhere and get a connection error.
Record updates take from a few minutes to a few hours. You can check the result with:
dig +short panel.example.com
Grey and orange cloudâ
In the Cloudflare dashboard, a record has a proxy toggle:
- grey cloud â the record returns your server's address to the client directly; the certificate is issued and renewed on the server, and IP restrictions work as usual;
- orange cloud â traffic goes through Cloudflare, and the client sees Cloudflare addresses instead of yours. This hides the server and smooths out spikes, but it requires separate real client IP configuration (see the section below) and does not allow issuing a certificate via port 80 validation without additional conditions.
For the panel, the orange cloud is usually chosen, and trust for the
CF-Connecting-IP header must be configured. For details, see the
Cloudflare section.
Certificateâ
certbot â the main pathâ
certbot issues a Let's Encrypt certificate and renews it on its own timer. First create a complete HTTP host: it serves domain ownership validation and redirects all other requests to HTTPS.
sudo mkdir -p /var/www/certbot
sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
sudo nano /etc/nginx/sites-available/xlada-bootstrap
# Temporary host for issuing certificates for the panel and the subscription page.
server {
listen 80;
listen [::]:80;
server_name panel.example.com sub.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
sudo ln -sf /etc/nginx/sites-available/xlada-bootstrap /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d panel.example.com
sudo certbot --nginx -d sub.example.com
Certbot will issue a separate certificate for each domain: they will land in
/etc/letsencrypt/live/panel.example.com/ and
/etc/letsencrypt/live/sub.example.com/. After issuing, delete the temporary
host and enable the complete configurations from the sections below: the
certificate paths in them are already specified.
sudo rm -f /etc/nginx/sites-enabled/xlada-bootstrap
certbot performs renewal itself. Check it like this:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
acme.sh â alternativeâ
If certbot is unavailable or you prefer a single tool for several servers, use acme.sh with ALPN domain validation. This method does not depend on the web server, but requires a free port 8443.
sudo apt-get install -y cron socat
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc
sudo mkdir -p /opt/xlada/nginx && cd /opt/xlada/nginx
# One certificate for both domains at once: issue and pin the paths
acme.sh --issue --standalone -d 'panel.example.com' -d 'sub.example.com' \
--key-file /opt/xlada/nginx/privkey.key \
--fullchain-file /opt/xlada/nginx/fullchain.pem \
--alpn --tlsport 8443 \
--reloadcmd "systemctl reload nginx"
# Pin the paths and the reload command for automatic renewal
acme.sh --install-cert -d 'panel.example.com' \
--key-file /opt/xlada/nginx/privkey.key \
--fullchain-file /opt/xlada/nginx/fullchain.pem \
--reloadcmd "systemctl reload nginx"
# Bring the file names in line with those specified in the complete configs below
sudo mkdir -p /etc/letsencrypt/live/panel.example.com
sudo mkdir -p /etc/letsencrypt/live/sub.example.com
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/panel.example.com/fullchain.pem
sudo ln -sf /opt/xlada/nginx/privkey.key /etc/letsencrypt/live/panel.example.com/privkey.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/panel.example.com/chain.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/sub.example.com/fullchain.pem
sudo ln -sf /opt/xlada/nginx/privkey.key /etc/letsencrypt/live/sub.example.com/privkey.pem
sudo ln -sf /opt/xlada/nginx/fullchain.pem /etc/letsencrypt/live/sub.example.com/chain.pem
Port 8443 must be open and not used by another process. acme.sh renews the certificate itself about every 60 days via cron â make sure the cron service is running.
If nginx runs in a container (see below), in --reloadcmd specify a reload
inside the container instead of a service reload:
docker exec xlada-nginx nginx -s reload.
Keep the certificate authority limitation in mind: the .ru, .su, and
.ŅŅ domain zones are not supported when issuing via ZeroSSL,
which acme.sh uses by default. For such domains, choose certbot or another
certificate authority.
nginx configuration for the panelâ
Create the virtual host file and enable it:
sudo nano /etc/nginx/sites-available/xlada-panel
sudo ln -sf /etc/nginx/sites-available/xlada-panel /etc/nginx/sites-enabled/
# XLADA panel virtual host.
upstream xlada_panel {
server 127.0.0.1:3000;
keepalive 32;
}
# WebSocket needs the upgrade value, regular requests need close.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# âââ HTTP: domain ownership validation and redirect to HTTPS âââââââââ
server {
listen 80;
listen [::]:80;
server_name panel.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# âââ HTTPS âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;
# Let's Encrypt certificate
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/panel.example.com/chain.pem;
# Protocols and ciphers: Mozilla Intermediate recommendations
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# Sessions and certificate revocation checking
ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=60s;
resolver_timeout 2s;
# Security headers. Enable HSTS only after making sure that HTTPS
# works: the browser will remember this requirement for a long time.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Request body size: headroom for uploading files through the interface
client_max_body_size 25m;
client_body_timeout 60s;
# Response compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/wasm
application/xhtml+xml
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
# âââ Interface static files: the Vite build lives in /assets/ âââââ
# File names contain a hash, so they can be cached for a long time.
location ^~ /assets/ {
proxy_pass http://xlada_panel;
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_set_header Connection "";
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# âââ API and streaming responses: no buffering ââââââââââââââââââââ
location /api/ {
proxy_pass http://xlada_panel;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
# âââ Panel interface ââââââââââââââââââââââââââââââââââââââââââââââ
location / {
proxy_pass http://xlada_panel;
proxy_http_version 1.1;
# Without these headers the panel drops the connection
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;
# WebSocket: SSH terminal to nodes and live interface updates
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# The terminal keeps the connection open for hours
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering on;
}
}
A separate stub block closes requests that arrive by IP address or with a wrong server name: no certificate is served to such clients at all.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
Check the configuration and reload nginx:
sudo nginx -t && sudo systemctl reload nginx
IP allowlistâ
The base configuration has no IP-based access control: the panel is protected by
its own authentication, and the subscription page must be open to all clients. If
the panel is accessible only to you, use the complete config of the same host
with an address filter: it is given below and is written to the same file
/etc/nginx/sites-available/xlada-panel.
# XLADA panel virtual host with an IP allowlist.
upstream xlada_panel {
server 127.0.0.1:3000;
keepalive 32;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# âââ HTTP: domain ownership validation and redirect to HTTPS âââââââââ
server {
listen 80;
listen [::]:80;
server_name panel.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# âââ HTTPS only for trusted addresses ââââââââââââââââââââââââââââââââ
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;
# Allow only trusted addresses, return 403 to everyone else
allow 203.0.113.0/24;
allow 198.51.100.7;
deny all;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/panel.example.com/chain.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=60s;
resolver_timeout 2s;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 25m;
client_body_timeout 60s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/wasm
application/xhtml+xml
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
location ^~ /assets/ {
proxy_pass http://xlada_panel;
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_set_header Connection "";
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location /api/ {
proxy_pass http://xlada_panel;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
location / {
proxy_pass http://xlada_panel;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering on;
}
}
Apply such a filter only to the panel host. On the subscription page host it will break link delivery to users, and on the API it will break webhooks and external integrations. Basic auth as a second layer is inconvenient: it breaks API tokens and service requests, so rely on the administrator password and TOTP instead.
nginx in a containerâ
If your nginx also runs in Docker, enable host network mode for it: then the
container sees the same 127.0.0.1:3000 and 127.0.0.1:3010 as the host, and the
complete configurations from this page work without a single edit.
services:
xlada-nginx:
image: nginx:1.30
container_name: xlada-nginx
restart: unless-stopped
# host mode: the upstreams 127.0.0.1:3000 and 127.0.0.1:3010 are reachable as on the host
network_mode: host
volumes:
- /etc/nginx/sites-available/xlada-panel:/etc/nginx/conf.d/xlada-panel.conf:ro
- /etc/nginx/sites-available/xlada-subpage:/etc/nginx/conf.d/xlada-subpage.conf:ro
- /etc/nginx/conf.d/00-default.conf:/etc/nginx/conf.d/00-default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
cd /opt/xlada/nginx
docker compose up -d
docker exec xlada-nginx nginx -t
docker exec xlada-nginx nginx -s reload
Updating the configuration comes down to editing the files in
/etc/nginx/sites-available/ and reloading inside the container:
docker exec xlada-nginx nginx -t && docker exec xlada-nginx nginx -s reload
Subscription pageâ
The subscription page runs on 127.0.0.1:3010 in the xpanel-subpage container
and lives on a separate subdomain. The requirements are the same: HTTPS and the
X-Forwarded-For and X-Forwarded-Proto: https headers, otherwise the container
drops the connection. WebSocket is not needed here, and the timeout can be
shorter.
# XLADA subscription page virtual host.
upstream xlada_subpage {
server 127.0.0.1:3010;
keepalive 16;
}
# The subscription page does not need WebSocket, but the headers are kept:
# that way the config stays working if the application starts using them.
map $http_upgrade $subpage_connection_upgrade {
default upgrade;
'' close;
}
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_trusted_certificate /etc/letsencrypt/live/sub.example.com/chain.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:XLADA_TLS:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=60s;
resolver_timeout 2s;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 10m;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
application/xml
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
# Subscription page build static files
location ^~ /assets/ {
proxy_pass http://xlada_subpage;
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_set_header Connection "";
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location / {
proxy_pass http://xlada_subpage;
proxy_http_version 1.1;
# Without these headers the subscription page drops the connection
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;
# WebSocket: not used right now, but the config is ready for it
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $subpage_connection_upgrade;
proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering on;
}
}
Enable the host and reload nginx:
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
If CUSTOM_SUB_PREFIX is set in the panel, the same path must be added to
SUB_PUBLIC_DOMAIN â see Subscription page for
details. And in the subscription page's own environment file, keep
TRUST_PROXY=1 so that it trusts one proxy hop and takes the client IP from the
header.
Real client IPâ
The panel must see the real client address, because the following depend on it:
- device accounting by HWID â device binding and request history store the request address (Device limit);
- admin audit log: the address shows who logged in and from where (Panel security);
- brute-force protection on login: login attempts are counted by address, and if all requests come from a single proxy address, the limit will either trigger for everyone at once or not trigger at all.
If Cloudflare sits in front of nginx, the usual X-Forwarded-For is useless:
Cloudflare passes the client address in the CF-Connecting-IP header. This
header can be trusted only from Cloudflare subnets â otherwise anyone can put
someone else's address into a request.
# Take the client address from the Cloudflare header.
# The list of Cloudflare ranges changes from time to time â verify it.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
If your own load balancer or a second proxy sits in front of nginx, trust its subnet and the usual header:
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Never specify set_real_ip_from 0.0.0.0/0 and never trust X-Forwarded-For
from the entire internet. And do not replace X-Forwarded-Proto with the value
http manually: the panel will refuse to work.
Verificationâ
# 1. Syntax and reload
sudo nginx -t && sudo systemctl reload nginx
# 2. Redirect from HTTP to HTTPS â expect 301
curl -sI http://panel.example.com | head -1
# 3. HTTPS: response code and HSTS header
curl -sI https://panel.example.com | grep -Ei 'HTTP/|strict-transport'
# 4. Certificate expiration date
echo | openssl s_client -connect panel.example.com:443 \
-servername panel.example.com 2>/dev/null | openssl x509 -noout -dates
# 5. Subscription page â expect 200 and the profile-web-page-url header
curl -sI https://sub.example.com/AbCdEf1234567890
What to look at if something does not work:
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log
sudo journalctl -u nginx -n 50 --no-pager
docker compose logs --tail 50 xpanel
Typical signs: 502 Bad Gateway â the panel container is not running or listens
on the wrong port; TLS handshake errors â a wrong certificate path or an expired
certificate; a connection drop right after the request â the X-Forwarded-For or
X-Forwarded-Proto headers are lost. In the access log, the client address must
match its real address, not the Cloudflare address.