Skip to main content

Caddy

Overview​

Caddy is a reverse proxy that obtains and renews Let's Encrypt certificates on its own: no separate ACME client, cron job, or manual reload is needed. The XLADA panel and the subscription page only work behind a proxy with HTTPS — their middleware drops the connection if a request arrives without the X-Forwarded-For and X-Forwarded-Proto: https headers.

Caddy sets X-Forwarded-* itself. In the configuration below we still set them explicitly: this way the behavior does not change if a CDN or load balancer appears in front of Caddy, and X-Forwarded-Proto will not turn into http.

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 that the xpanel and xpanel-subpage containers publish on the host loopback.

warning

You can only continue with a registered domain: without one, Let's Encrypt will not issue a certificate.

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 added under DNS → Records → Create record: type A, panel or sub in the name field, and the server IP in the address field.

  • Grey cloud — traffic goes directly to your server, and Caddy sees the real client IP. Choose this mode if Cloudflare is unavailable or gets in the way.
  • Orange cloud — traffic passes through the Cloudflare network: the server IP is hidden and attack protection works. Here you must specify the Cloudflare ranges in trusted_proxies static in the Caddyfile, otherwise all clients will look like one Cloudflare address, and HWID limits, audit, and login brute-force protection will become meaningless.

Wait for the records to update: dig +short panel.example.com must return the server address, and with the orange cloud, Cloudflare addresses.

Certificates​

Caddy obtains the certificate automatically as soon as the domain is listed in the Caddyfile. Verifying the domain requires open ports 80 (HTTP-01) and 443 (TLS-ALPN-01); renewal is also automatic — about 30 days before expiry. In the global block it is enough to specify email: notifications about certificate problems will be sent to this address.

Full Caddyfile​

Create the directory and the file:

sudo mkdir -p /opt/caddy && cd /opt/caddy
sudo nano Caddyfile
/opt/caddy/Caddyfile
# ─── Global options ──────────────────────────────────────────────────
{
# Email for Let's Encrypt notifications
email admin@example.com

# Real client IP behind Cloudflare. Cloudflare publishes the ranges;
# their list is worth checking against the current one periodically.
servers {
trusted_proxies static \
173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 \
141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 \
197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 \
104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 \
2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 \
2a06:98c0::/29 2c0f:f248::/32
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
}

# ─── XLADA panel ─────────────────────────────────────────────────────
panel.example.com {
encode zstd gzip

# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}

# Build assets: the hash in the file name allows caching them for a long time
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"

reverse_proxy 127.0.0.1:3000 {
# The panel drops the connection without these two headers.
# We overwrite X-Forwarded-For so a client cannot spoof its IP.
header_up X-Forwarded-For {client_ip}
header_up X-Forwarded-Proto https
header_up X-Real-IP {client_ip}
header_up Host {host}

# WebSocket: Caddy handles Upgrade and Connection itself,
# no separate headers are needed. flush_interval -1 disables
# buffering — this matters for SSE and statistics streaming.
flush_interval -1
}
}

# ─── Subscription page ───────────────────────────────────────────────
sub.example.com {
encode zstd gzip

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}

# Build assets: the hash in the file name allows caching them for a long time
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"

reverse_proxy 127.0.0.1:3010 {
header_up X-Forwarded-For {client_ip}
header_up X-Forwarded-Proto https
header_up X-Real-IP {client_ip}
header_up Host {host}
flush_interval -1
}
}

# ─── Requests by IP and to unknown domains ───────────────────────────
:443 {
tls internal
respond 204
}

Key directives:

DirectivePurpose
trusted_proxies statictrust Cloudflare headers from these subnets
client_ip_headerstake the real IP from CF-Connecting-IP
header_up X-Forwarded-Forpass the real client IP to the panel
header_up X-Forwarded-Proto httpsthe panel accepts only https
flush_interval -1streaming without buffering
@assets and headerlong cache for build assets from /assets/
:443 with tls internalcuts off scanners hitting the IP
warning

Do not add extra HTTP authentication to the sub.example.com domain: the subscription page is requested by client apps that cannot enter a login and password. Protect panel login with its own authentication and TOTP.

Running in Docker​

/opt/caddy/docker-compose.yml
services:
caddy:
image: caddy:2.9
container_name: caddy
restart: unless-stopped
# host mode is needed so Caddy can see 127.0.0.1:3000 and 127.0.0.1:3010
network_mode: host
environment:
- TZ=UTC
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data:/data # ACME certificates and keys
- ./config:/config # Caddy service state
cd /opt/caddy
docker compose up -d
docker compose logs -f -t

Host mode is chosen deliberately: only this way does the Caddy container see the loopback ports of the panel and the subscription page, while the certificates stay on the host. Caddy itself listens on ports 80 and 443, and 3000, 3010 and 3001 are not exposed.

Validation and reload​

# Check the syntax without restarting
caddy validate --config /opt/caddy/Caddyfile

# Bring the file to canonical form
caddy fmt --overwrite /opt/caddy/Caddyfile

# Graceful reload: connections are not dropped
systemctl reload caddy

# In Docker, the same commands run inside the container
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

What to look for in the logs​

journalctl -u caddy -f # host install
docker compose logs -f caddy # Docker run
  • certificate obtained successfully — certificate issued;
  • serving initial configuration — configuration accepted;
  • tls.obtain or acme — check DNS, ports 80 and 443, Let's Encrypt limits;
  • dial tcp 127.0.0.1:3000: connect: connection refused — the panel is not running.

Make sure both sites respond:

curl -I https://panel.example.com
curl -I https://sub.example.com

The response must start with HTTP/2 200. The real client IP is conveniently checked in the panel audit: if it shows Cloudflare addresses, then trusted_proxies is not enabled.

What's next​