Skip to main content

Angie

Overview​

Angie is a fork of nginx with an extended module set, a built-in ACME client, and HTTP/3 support. For the XLADA panel it is used just like nginx: it terminates TLS and proxies requests to the loopback ports of the panel and the subscription page.

The panel and the subscription page must receive the X-Forwarded-For and X-Forwarded-Proto: https headers. Their code contains middleware that drops the connection if the headers are missing: without them the panel will not start behind a proxy. The real client IP is also needed for HWID restrictions, the audit of administrator actions, and login brute-force protection.

The examples use the domains panel.example.com and sub.example.com, with the upstreams 127.0.0.1:3000 (panel) and 127.0.0.1:3010 (subscription page) — these are the ports of the xpanel and xpanel-subpage containers on the host loopback.

warning

A registered domain is required. Let's Encrypt will not issue a certificate until the domain points to your server.

DNS​

Check the server IP address (a static one is preferable) and create records with your DNS provider or registrar:

TypeNameValue
ApanelServer IPv4 address
AsubServer IPv4 address
AAAApanelServer IPv6 address
AAAAsubServer IPv6 address

In Cloudflare, records are created under DNS → Records → Create record.

  • Grey cloud — traffic goes directly, and Angie sees the real client IP; use this mode if Cloudflare is unavailable or gets in the way.
  • Orange cloud — traffic goes through the Cloudflare network: the server IP is hidden and attack protection works. In this mode Angie sees Cloudflare addresses, so for a correct IP enable the realip module and trust for the CF-Connecting-IP header.

TLS certificates​

Create directories for the certificates and for ACME checks:

sudo mkdir -p /etc/angie/ssl /var/www/acme

Option 1: certbot​

sudo apt-get update && sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/acme \
-d panel.example.com -d sub.example.com \
--email admin@example.com --agree-tos --no-eff-email

sudo ln -sf /etc/letsencrypt/live/panel.example.com/fullchain.pem /etc/angie/ssl/fullchain.pem
sudo ln -sf /etc/letsencrypt/live/panel.example.com/privkey.pem /etc/angie/ssl/privkey.key

Option 2: acme.sh​

curl https://get.acme.sh | sh -s email=admin@example.com

acme.sh --issue --webroot /var/www/acme \
-d panel.example.com -d sub.example.com \
--key-file /etc/angie/ssl/privkey.key \
--fullchain-file /etc/angie/ssl/fullchain.pem \
--reloadcmd "docker exec angie angie -s reload"

Both clients renew the certificate automatically: certbot with its systemd timer, acme.sh with a cron job every 60 days. Port 80 must be open and must answer on the ACME check path that we describe in the configuration below.

Angie also has its own ACME client: it issues a certificate without certbot and acme.sh. The full config for this case is given in the subsection Option with built-in ACME.

Full angie.conf configuration​

sudo mkdir -p /opt/angie && cd /opt/angie
sudo nano angie.conf
/opt/angie/angie.conf
# ─── Upstreams: the xpanel and xpanel-subpage containers ─────────────
upstream xpanel_panel {
server 127.0.0.1:3000;
keepalive 32;
}

upstream xpanel_subpage {
server 127.0.0.1:3010;
keepalive 16;
}

# WebSocket: set the Connection header only on Upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

# ─── Gzip for all virtual hosts ──────────────────────────────────────
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/xhtml+xml
application/xml
application/wasm
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

# ─── HTTP: ACME challenge and redirect to HTTPS ──────────────────────
server {
listen 80;
listen [::]:80;
server_name panel.example.com sub.example.com;

location /.well-known/acme-challenge/ {
root /var/www/acme;
}

location / {
return 301 https://$host$request_uri;
}
}

# ─── XLADA panel ─────────────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;

# TLS (Mozilla Intermediate)
ssl_certificate /etc/angie/ssl/fullchain.pem;
ssl_certificate_key /etc/angie/ssl/privkey.key;
ssl_trusted_certificate /etc/angie/ssl/fullchain.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;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL: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 208.67.222.222 208.67.220.220 valid=60s;
resolver_timeout 2s;

# Security headers
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 — with headroom for file uploads through the UI
client_max_body_size 25m;

# UI assets: the hash in the file name allows caching them for a long time
location ^~ /assets/ {
proxy_http_version 1.1;
proxy_pass http://xpanel_panel;

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 https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_http_version 1.1;
proxy_pass http://xpanel_panel;

# The panel drops the connection without these two headers
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 https;
proxy_set_header X-Forwarded-Host $host;

# WebSocket — SSH terminal to nodes
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; # streaming and SSE
proxy_cache off;
}
}

# ─── Subscription page ───────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name sub.example.com;

ssl_certificate /etc/angie/ssl/fullchain.pem;
ssl_certificate_key /etc/angie/ssl/privkey.key;
ssl_trusted_certificate /etc/angie/ssl/fullchain.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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL: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; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;

client_max_body_size 10m;

# Subscription page build assets
location ^~ /assets/ {
proxy_http_version 1.1;
proxy_pass http://xpanel_subpage;

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 https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_http_version 1.1;
proxy_pass http://xpanel_subpage;

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 https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering off;
}
}

# ─── Reject TLS handshakes by IP and to unknown domains ──────────────
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;

ssl_reject_handshake on;
}

When installing on the host, copy the file to /etc/angie/http.d/xlada.conf. When running in Docker, it is mounted into the container as a whole, as shown below.

We do not use the reuseport directive: it is incompatible with several virtual hosts on the same address and port — Angie will exit with the error duplicate listen options.

Option with built-in ACME​

If external clients are not needed, Angie issues the certificate itself. The config below is self-contained: nothing has to be issued in advance, Angie will obtain the certificate on the first start and will renew it automatically. Domain ownership is validated over TLS-ALPN on port 443, so port 80 only redirects. ACME state is kept in /var/lib/angie/acme/ — this directory must survive a container restart.

/opt/angie/angie.conf
# ─── Upstreams: the xpanel and xpanel-subpage containers ─────────────
upstream xpanel_panel {
server 127.0.0.1:3000;
keepalive 32;
}

upstream xpanel_subpage {
server 127.0.0.1:3010;
keepalive 16;
}

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

# ─── Built-in ACME ───────────────────────────────────────────────────
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=60s;
resolver_timeout 2s;
acme_client acme_le https://acme-v02.api.letsencrypt.org/directory;

# ─── Gzip for all virtual hosts ──────────────────────────────────────
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/xhtml+xml
application/xml
application/wasm
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

# ─── HTTP: redirect to HTTPS ─────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name panel.example.com sub.example.com;

location / {
return 301 https://$host$request_uri;
}
}

# ─── XLADA panel ─────────────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name panel.example.com;

acme acme_le;
ssl_certificate $acme_cert_acme_le;
ssl_certificate_key $acme_cert_key_acme_le;

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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:ACME_TLS:10m;
ssl_session_tickets off;

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;

location ^~ /assets/ {
proxy_http_version 1.1;
proxy_pass http://xpanel_panel;

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 https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_http_version 1.1;
proxy_pass http://xpanel_panel;

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 https;
proxy_set_header X-Forwarded-Host $host;

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;
}
}

# ─── Subscription page ───────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name sub.example.com;

acme acme_le;
ssl_certificate $acme_cert_acme_le;
ssl_certificate_key $acme_cert_key_acme_le;

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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:ACME_TLS:10m;
ssl_session_tickets off;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;

client_max_body_size 10m;

location ^~ /assets/ {
proxy_http_version 1.1;
proxy_pass http://xpanel_subpage;

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 https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_http_version 1.1;
proxy_pass http://xpanel_subpage;

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 https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffering off;
}
}

# ─── Reject TLS handshakes by IP and to unknown domains ──────────────
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;

ssl_reject_handshake on;
}

Check the configuration and reload Angie:

sudo angie -t && sudo angie -s reload
docker compose exec angie angie -t && docker compose exec angie angie -s reload

Running in Docker​

/opt/angie/docker-compose.yml
services:
angie:
image: docker.angie.software/angie:1.9.0
container_name: angie
restart: unless-stopped
# host mode so Angie can see 127.0.0.1:3000 and 127.0.0.1:3010
network_mode: host
environment:
- TZ=UTC
volumes:
- ./angie.conf:/etc/angie/http.d/default.conf:ro
- /etc/angie/ssl:/etc/angie/ssl:ro # Let's Encrypt certificates
- /var/www/acme:/var/www/acme:ro # ACME challenge directory
- angie-ssl-data:/var/lib/angie/acme/ # built-in ACME state

volumes:
angie-ssl-data:
name: angie-ssl-data
cd /opt/angie
docker compose up -d
docker compose logs -f -t

Host mode is chosen deliberately: the container sees the same loopback ports 127.0.0.1:3000 and 127.0.0.1:3010 as the host, so the upstreams in angie.conf do not need to be rewritten to container names. The certificates and the ACME challenge directory stay on the host.

Validation and reload​

# Check the syntax
sudo angie -t
docker compose exec angie angie -t

# Full dump of the resulting configuration
sudo angie -T

# Graceful reload without dropping connections
sudo angie -s reload
docker compose exec angie angie -s reload

What to look for in the logs​

journalctl -u angie -f # host install
docker compose logs -f -t angie
  • [emerg] — configuration error, angie -t will show the line;
  • connect() failed (111: Connection refused) — the panel or the subscription page is not running on the expected port;
  • upstream timed out — increase proxy_read_timeout for long operations;
  • SSL_do_handshake() failed — ordinary scanners, no action required.
curl -I https://panel.example.com
curl -I https://sub.example.com

Both responses must start with HTTP/2 200. If the panel audit shows Cloudflare addresses, enable trust for CF-Connecting-IP through the realip module.

What's next​