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.
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:
| Type | Name | Value |
|---|---|---|
A | panel | Server IPv4 address |
A | sub | Server IPv4 address |
AAAA | panel | Server IPv6 address |
AAAA | sub | Server 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
realipmodule and trust for theCF-Connecting-IPheader.
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
# âââ 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.
# âââ 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â
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 -twill show the line;connect() failed (111: Connection refused)â the panel or the subscription page is not running on the expected port;upstream timed outâ increaseproxy_read_timeoutfor 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.